AI助手优化
个人信息可以修改 WEB更新
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package com.accounting.controller;
|
package com.accounting.controller;
|
||||||
|
|
||||||
import com.accounting.config.AiConfig;
|
import com.accounting.config.AiConfig;
|
||||||
|
import com.accounting.dto.AvatarUploadResponse;
|
||||||
import com.accounting.dto.ai.AiConfigRequest;
|
import com.accounting.dto.ai.AiConfigRequest;
|
||||||
import com.accounting.dto.ai.AiConfigResponse;
|
import com.accounting.dto.ai.AiConfigResponse;
|
||||||
import com.accounting.dto.ai.AiPersonaRequest;
|
import com.accounting.dto.ai.AiPersonaRequest;
|
||||||
@@ -19,6 +20,7 @@ import com.accounting.mapper.UserMapper;
|
|||||||
import com.accounting.service.AiChatService;
|
import com.accounting.service.AiChatService;
|
||||||
import com.accounting.service.AiConfigService;
|
import com.accounting.service.AiConfigService;
|
||||||
import com.accounting.service.AiPersonaService;
|
import com.accounting.service.AiPersonaService;
|
||||||
|
import com.accounting.service.AvatarService;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
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.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -69,6 +73,9 @@ public class AiController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AiPersonaService aiPersonaService;
|
private AiPersonaService aiPersonaService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AvatarService avatarService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ChatSessionMapper sessionMapper;
|
private ChatSessionMapper sessionMapper;
|
||||||
|
|
||||||
@@ -84,7 +91,7 @@ public class AiController {
|
|||||||
return Map.of("enabled", aiConfig.isReady());
|
return Map.of("enabled", aiConfig.isReady());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "会话列表(按最近活跃倒序)")
|
@Operation(summary = "会话列表(按最近活跃倒序,带人设信息)")
|
||||||
@GetMapping("/sessions")
|
@GetMapping("/sessions")
|
||||||
public List<SessionItem> sessions(Authentication authentication) {
|
public List<SessionItem> sessions(Authentication authentication) {
|
||||||
Long userId = getUserId(authentication);
|
Long userId = getUserId(authentication);
|
||||||
@@ -99,6 +106,13 @@ public class AiController {
|
|||||||
item.setId(s.getId());
|
item.setId(s.getId());
|
||||||
item.setTitle(s.getTitle());
|
item.setTitle(s.getTitle());
|
||||||
item.setUpdateTime(s.getUpdateTime());
|
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;
|
return item;
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -181,6 +195,15 @@ public class AiController {
|
|||||||
return Map.of("success", true);
|
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 配置
|
// ---------------------------------------------------------------- AI 配置
|
||||||
|
|
||||||
@Operation(summary = "AI 配置列表(第一项是虚拟的「默认」,值来自服务端 .env)")
|
@Operation(summary = "AI 配置列表(第一项是虚拟的「默认」,值来自服务端 .env)")
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
package com.accounting.controller;
|
package com.accounting.controller;
|
||||||
|
|
||||||
import com.accounting.dto.AuthResponse;
|
import com.accounting.dto.AuthResponse;
|
||||||
|
import com.accounting.dto.AvatarUploadResponse;
|
||||||
import com.accounting.dto.LoginRequest;
|
import com.accounting.dto.LoginRequest;
|
||||||
import com.accounting.dto.RegisterRequest;
|
import com.accounting.dto.RegisterRequest;
|
||||||
|
import com.accounting.dto.UpdateProfileRequest;
|
||||||
import com.accounting.service.AuthService;
|
import com.accounting.service.AuthService;
|
||||||
|
import com.accounting.service.AvatarService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@Tag(name = "认证管理", description = "用户注册、登录接口")
|
/**
|
||||||
|
* 认证与个人资料。
|
||||||
|
*
|
||||||
|
* <p>头像放在这里而不是单开一个 UserController:App 里只有「我的」页面用得到,
|
||||||
|
* 而且它天然跟登录态绑定 —— 换完头像要返回新的用户信息给前端刷新。</p>
|
||||||
|
*/
|
||||||
|
@Tag(name = "认证管理", description = "用户注册、登录、个人资料接口")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
@@ -19,6 +30,9 @@ public class AuthController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AuthService authService;
|
private AuthService authService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AvatarService avatarService;
|
||||||
|
|
||||||
@Operation(summary = "用户注册")
|
@Operation(summary = "用户注册")
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
@@ -32,13 +46,26 @@ public class AuthController {
|
|||||||
AuthResponse response = authService.login(request);
|
AuthResponse response = authService.login(request);
|
||||||
return ResponseEntity.ok(response);
|
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 username;
|
||||||
private String nickname;
|
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.token = token;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.nickname = nickname;
|
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 字")
|
@Size(max = 8000, message = "提示词最长 8000 字")
|
||||||
private String systemPrompt;
|
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 字」用,省得在客户端数) */
|
/** 提示词字数(前端显示「1234 字」用,省得在客户端数) */
|
||||||
private Integer promptLength;
|
private Integer promptLength;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像相对路径(如 {@code persona/2026/09/29/xxx.jpg})。null = 用默认头像。
|
||||||
|
*
|
||||||
|
* <p>是相对路径不是完整 URL —— 前端自己拼 {@code /uploads/} 前缀。</p>
|
||||||
|
*/
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
/** 是否是「默认」那一项 */
|
/** 是否是「默认」那一项 */
|
||||||
private Boolean isDefault;
|
private Boolean isDefault;
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,21 @@ public record EffectivePersona(
|
|||||||
/** 显示名,出错时告诉用户「是哪个配置挂了」,也用于日志 */
|
/** 显示名,出错时告诉用户「是哪个配置挂了」,也用于日志 */
|
||||||
String label,
|
String label,
|
||||||
/** 是不是内置默认。默认项不允许被删,前端也不给编辑入口 */
|
/** 是不是内置默认。默认项不允许被删,前端也不给编辑入口 */
|
||||||
boolean isDefault
|
boolean isDefault,
|
||||||
|
/**
|
||||||
|
* 人设 id。**内置默认时为 null**。
|
||||||
|
*
|
||||||
|
* <p>带上它是为了「新建会话时把人设绑到会话上」—— 没有 id 就没法落库,
|
||||||
|
* 而按名字反查会在重名时选错。</p>
|
||||||
|
*/
|
||||||
|
Long id
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public static final String DEFAULT_LABEL = "默认人设";
|
public static final String DEFAULT_LABEL = "默认人设";
|
||||||
|
|
||||||
/** 用内置默认文本 */
|
/** 用内置默认文本 */
|
||||||
public static EffectivePersona fromDefaults(String defaultPrompt) {
|
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(
|
return new EffectivePersona(
|
||||||
entity.getSystemPrompt() == null ? "" : entity.getSystemPrompt(),
|
entity.getSystemPrompt() == null ? "" : entity.getSystemPrompt(),
|
||||||
entity.getName() == null ? "未命名" : entity.getName(),
|
entity.getName() == null ? "未命名" : entity.getName(),
|
||||||
false);
|
false,
|
||||||
|
entity.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,17 @@ public class SessionItem {
|
|||||||
|
|
||||||
/** 最近活跃时间,列表按它倒序 */
|
/** 最近活跃时间,列表按它倒序 */
|
||||||
private LocalDateTime updateTime;
|
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;
|
private String systemPrompt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像相对路径(如 {@code persona/2026/09/29/xxx.jpg}),不是完整 URL。
|
||||||
|
*
|
||||||
|
* <p>存相对路径是为了换域名/换服务器时不用洗数据 —— 前端拿到后自己拼
|
||||||
|
* {@code /uploads/} 前缀。null 表示用默认头像。</p>
|
||||||
|
*/
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
@TableLogic
|
@TableLogic
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ public class ChatSession {
|
|||||||
/** 标题,首条消息时自动取前 20 字 */
|
/** 标题,首条消息时自动取前 20 字 */
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本会话用的人设 ID。**新建会话时确定,之后不再改**。
|
||||||
|
*
|
||||||
|
* <p>存在会话上而不是每条消息上,是因为人设的粒度是「这次对话用谁」,
|
||||||
|
* 不是「这句话用谁」—— 聊到一半换性格会很怪。</p>
|
||||||
|
*
|
||||||
|
* <p>null 表示用代码内置的默认人设。不建外键:人设被删了之后历史会话
|
||||||
|
* 仍要能打开,指向一个不存在的 id 时服务端会静默降级到默认。</p>
|
||||||
|
*/
|
||||||
|
private Long personaId;
|
||||||
|
|
||||||
@TableLogic
|
@TableLogic
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ public class User {
|
|||||||
|
|
||||||
private String nickname;
|
private String nickname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像相对路径(如 {@code avatar/2026/09/29/xxx.jpg}),不是完整 URL。
|
||||||
|
*
|
||||||
|
* <p>和 App「我的」页面共用同一个字段。null 表示用默认头像。</p>
|
||||||
|
*/
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
private LocalDateTime updateTime;
|
private LocalDateTime updateTime;
|
||||||
|
|||||||
@@ -100,10 +100,16 @@ public class AiChatService {
|
|||||||
SseEmitter emitter, EffectiveAiConfig effective,
|
SseEmitter emitter, EffectiveAiConfig effective,
|
||||||
EffectivePersona persona) {
|
EffectivePersona persona) {
|
||||||
try {
|
try {
|
||||||
ChatSession session = resolveSession(userId, sessionId, content);
|
ChatSession session = resolveSession(userId, sessionId, content, persona);
|
||||||
saveMessage(session.getId(), ROLE_USER, content);
|
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);
|
String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective);
|
||||||
|
|
||||||
if (assistantText.isBlank()) {
|
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) {
|
if (sessionId != null) {
|
||||||
ChatSession session = sessionMapper.selectById(sessionId);
|
ChatSession session = sessionMapper.selectById(sessionId);
|
||||||
if (session == null || !session.getUserId().equals(userId)) {
|
if (session == null || !session.getUserId().equals(userId)) {
|
||||||
@@ -354,6 +370,8 @@ public class AiChatService {
|
|||||||
ChatSession session = new ChatSession();
|
ChatSession session = new ChatSession();
|
||||||
session.setUserId(userId);
|
session.setUserId(userId);
|
||||||
session.setTitle(abbreviate(firstContent.trim(), 20));
|
session.setTitle(abbreviate(firstContent.trim(), 20));
|
||||||
|
// 内置默认人设不落库(persona_id 留 null)—— 和 ai_persona 的「默认项不入库」一致
|
||||||
|
session.setPersonaId(persona == null ? null : persona.id());
|
||||||
sessionMapper.insert(session);
|
sessionMapper.insert(session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public class AiPersonaService {
|
|||||||
|
|
||||||
private static final int MAX_NAME = 50;
|
private static final int MAX_NAME = 50;
|
||||||
private static final int MAX_PROMPT = 8000;
|
private static final int MAX_PROMPT = 8000;
|
||||||
|
private static final int MAX_AVATAR = 255;
|
||||||
|
|
||||||
/** 名字留空时,从提示词里截取多少字当兜底名字 */
|
/** 名字留空时,从提示词里截取多少字当兜底名字 */
|
||||||
private static final int FALLBACK_NAME_LEN = 12;
|
private static final int FALLBACK_NAME_LEN = 12;
|
||||||
@@ -91,6 +92,7 @@ public class AiPersonaService {
|
|||||||
- 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。""";
|
- 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。""";
|
||||||
|
|
||||||
private final AiPersonaMapper aiPersonaMapper;
|
private final AiPersonaMapper aiPersonaMapper;
|
||||||
|
private final AvatarService avatarService;
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 查询
|
// ---------------------------------------------------------------- 查询
|
||||||
|
|
||||||
@@ -157,6 +159,9 @@ public class AiPersonaService {
|
|||||||
public void delete(Long userId, Long id) {
|
public void delete(Long userId, Long id) {
|
||||||
AiPersonaEntity entity = requireOwned(userId, id);
|
AiPersonaEntity entity = requireOwned(userId, id);
|
||||||
aiPersonaMapper.deleteById(entity.getId());
|
aiPersonaMapper.deleteById(entity.getId());
|
||||||
|
// 顺手把人设头像也删了,别在磁盘上堆孤儿文件。
|
||||||
|
// 失败只是记日志 —— 删不掉一张图不该让「删人设」失败
|
||||||
|
avatarService.deleteQuietly(entity.getAvatar());
|
||||||
log.info("AI 人设已删除: userId={}, id={}", userId, id);
|
log.info("AI 人设已删除: userId={}, id={}", userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +192,21 @@ public class AiPersonaService {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- 最终 prompt 组装
|
// ---------------------------------------------------------------- 最终 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 用户文本 + 空行 + 环境信息}。
|
* 组装最终送给模型的 system prompt:{@code 用户文本 + 空行 + 环境信息}。
|
||||||
*
|
*
|
||||||
@@ -255,6 +275,40 @@ public class AiPersonaService {
|
|||||||
String name = request.getName() == null ? "" : request.getName().strip();
|
String name = request.getName() == null ? "" : request.getName().strip();
|
||||||
entity.setName(name.isEmpty() ? fallbackName(prompt) : truncate(name, MAX_NAME));
|
entity.setName(name.isEmpty() ? fallbackName(prompt) : truncate(name, MAX_NAME));
|
||||||
entity.setSystemPrompt(prompt);
|
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.setName(entity.getName());
|
||||||
response.setSystemPrompt(entity.getSystemPrompt());
|
response.setSystemPrompt(entity.getSystemPrompt());
|
||||||
response.setPromptLength(entity.getSystemPrompt() == null ? 0 : entity.getSystemPrompt().length());
|
response.setPromptLength(entity.getSystemPrompt() == null ? 0 : entity.getSystemPrompt().length());
|
||||||
|
response.setAvatar(entity.getAvatar());
|
||||||
response.setIsDefault(false);
|
response.setIsDefault(false);
|
||||||
response.setEditable(true);
|
response.setEditable(true);
|
||||||
// 用户项不带 defaultPrompt —— 它只在默认项上出现
|
// 用户项不带 defaultPrompt —— 它只在默认项上出现
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.accounting.config.JwtConfig;
|
|||||||
import com.accounting.dto.AuthResponse;
|
import com.accounting.dto.AuthResponse;
|
||||||
import com.accounting.dto.LoginRequest;
|
import com.accounting.dto.LoginRequest;
|
||||||
import com.accounting.dto.RegisterRequest;
|
import com.accounting.dto.RegisterRequest;
|
||||||
|
import com.accounting.dto.UpdateProfileRequest;
|
||||||
import com.accounting.entity.User;
|
import com.accounting.entity.User;
|
||||||
import com.accounting.mapper.UserMapper;
|
import com.accounting.mapper.UserMapper;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -30,6 +31,9 @@ public class AuthService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AuthenticationManager authenticationManager;
|
private AuthenticationManager authenticationManager;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AvatarService avatarService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AuthResponse register(RegisterRequest request) {
|
public AuthResponse register(RegisterRequest request) {
|
||||||
// 检查用户名是否已存在
|
// 检查用户名是否已存在
|
||||||
@@ -53,7 +57,7 @@ public class AuthService {
|
|||||||
// 生成token
|
// 生成token
|
||||||
String token = jwtConfig.generateToken(user.getUsername());
|
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) {
|
public AuthResponse login(LoginRequest request) {
|
||||||
@@ -75,12 +79,57 @@ public class AuthService {
|
|||||||
// 生成token
|
// 生成token
|
||||||
String token = jwtConfig.generateToken(user.getUsername());
|
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 '用户名',
|
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
|
||||||
`password` VARCHAR(255) NOT NULL COMMENT '密码(加密)',
|
`password` VARCHAR(255) NOT NULL COMMENT '密码(加密)',
|
||||||
`nickname` VARCHAR(50) COMMENT '昵称',
|
`nickname` VARCHAR(50) COMMENT '昵称',
|
||||||
|
`avatar` VARCHAR(255) COMMENT '头像相对路径;NULL = 用默认头像',
|
||||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
@@ -188,6 +189,7 @@ CREATE TABLE IF NOT EXISTS `chat_session` (
|
|||||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话ID',
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话ID',
|
||||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||||
`title` VARCHAR(100) NOT NULL DEFAULT '新对话' COMMENT '标题(首条消息自动生成)',
|
`title` VARCHAR(100) NOT NULL DEFAULT '新对话' COMMENT '标题(首条消息自动生成)',
|
||||||
|
`persona_id` BIGINT COMMENT '本会话用的人设ID,新建时确定且不可改;NULL = 内置默认人设',
|
||||||
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
|
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
|
||||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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',
|
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||||
`name` VARCHAR(50) NOT NULL COMMENT '显示名,如「花花」「简洁模式」「陪聊模式」',
|
`name` VARCHAR(50) NOT NULL COMMENT '显示名,如「花花」「简洁模式」「陪聊模式」',
|
||||||
`system_prompt` TEXT NOT NULL COMMENT '人设+运行规则。不含日期等环境信息——那些由代码运行时追加',
|
`system_prompt` TEXT NOT NULL COMMENT '人设+运行规则。不含日期等环境信息——那些由代码运行时追加',
|
||||||
|
`avatar` VARCHAR(255) COMMENT '头像相对路径;NULL = 用默认头像',
|
||||||
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
|
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
|
||||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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;
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
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';
|
'use strict';
|
||||||
|
|
||||||
var API = '/api';
|
var API = '/api';
|
||||||
|
// 静态资源根。后台和 /uploads 同源,所以是空串;
|
||||||
|
// 留成常量是为了以后把附件挪到独立域名/CDN 时只改这一处
|
||||||
|
var ASSET_URL = '';
|
||||||
var TOKEN_KEY = 'aq_note_admin_token';
|
var TOKEN_KEY = 'aq_note_admin_token';
|
||||||
var USER_KEY = 'aq_note_admin_user';
|
var USER_KEY = 'aq_note_admin_user';
|
||||||
|
|
||||||
@@ -1471,7 +1474,11 @@
|
|||||||
// 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项)
|
// 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项)
|
||||||
configId: null,
|
configId: null,
|
||||||
// 当前选中的人设 id;null = 用代码内置的默认人设。与 configId 互相独立
|
// 当前选中的人设 id;null = 用代码内置的默认人设。与 configId 互相独立
|
||||||
personaId: null
|
personaId: null,
|
||||||
|
// 当前会话绑的人设头像 URL(完整地址,已拼好 host)。气泡左侧用它
|
||||||
|
activePersonaAvatar: '',
|
||||||
|
// 当前登录用户的头像 URL。气泡右侧用它
|
||||||
|
userAvatar: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
function aiEnter() {
|
function aiEnter() {
|
||||||
@@ -1540,6 +1547,18 @@
|
|||||||
|
|
||||||
function aiOpenSession(id) {
|
function aiOpenSession(id) {
|
||||||
ai.sessionId = 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();
|
aiRenderSessions();
|
||||||
var box = $('ai-messages');
|
var box = $('ai-messages');
|
||||||
box.innerHTML = '<div class="ai-empty">加载中…</div>';
|
box.innerHTML = '<div class="ai-empty">加载中…</div>';
|
||||||
@@ -1590,12 +1609,43 @@
|
|||||||
function aiAppendBubble(role, content) {
|
function aiAppendBubble(role, content) {
|
||||||
var wrap = document.createElement('div');
|
var wrap = document.createElement('div');
|
||||||
wrap.className = 'ai-msg ' + role;
|
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');
|
var bubble = document.createElement('div');
|
||||||
bubble.className = 'ai-bubble';
|
bubble.className = 'ai-bubble';
|
||||||
var body = document.createElement('div');
|
var body = document.createElement('div');
|
||||||
body.className = 'ai-bubble-body';
|
body.className = 'ai-bubble-body';
|
||||||
bubble.appendChild(body);
|
bubble.appendChild(body);
|
||||||
|
|
||||||
|
// 对方头像在左、自己的在右 —— 和气泡方向一致
|
||||||
|
if (role === 'assistant') {
|
||||||
|
wrap.appendChild(avatar);
|
||||||
wrap.appendChild(bubble);
|
wrap.appendChild(bubble);
|
||||||
|
} else {
|
||||||
|
wrap.appendChild(bubble);
|
||||||
|
wrap.appendChild(avatar);
|
||||||
|
}
|
||||||
$('ai-messages').appendChild(wrap);
|
$('ai-messages').appendChild(wrap);
|
||||||
|
|
||||||
if (role === 'assistant') {
|
if (role === 'assistant') {
|
||||||
@@ -2192,6 +2242,19 @@
|
|||||||
$('persona-edit-cancel').onclick = personaCloseEditor;
|
$('persona-edit-cancel').onclick = personaCloseEditor;
|
||||||
$('persona-edit-save').onclick = personaSave;
|
$('persona-edit-save').onclick = personaSave;
|
||||||
$('persona-restore').onclick = personaRestoreDefault;
|
$('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-cfg').onclick = function (e) { if (e.target === this) personaClose(); };
|
||||||
$('persona-edit').onclick = function (e) { if (e.target === this) personaCloseEditor(); };
|
$('persona-edit').onclick = function (e) { if (e.target === this) personaCloseEditor(); };
|
||||||
// 边打字边检查提示词是否还提到「工具」——只提示,不阻止保存
|
// 边打字边检查提示词是否还提到「工具」——只提示,不阻止保存
|
||||||
@@ -2421,7 +2484,7 @@
|
|||||||
// · 「默认」是虚拟项(id 为 null),内容是代码内置的,不能改不能删
|
// · 「默认」是虚拟项(id 为 null),内容是代码内置的,不能改不能删
|
||||||
// · 当前选中存在浏览器本地,不动服务端 —— 免得和手机端互相打架
|
// · 当前选中存在浏览器本地,不动服务端 —— 免得和手机端互相打架
|
||||||
// · 提示词是用户输入,往 HTML 里插必须走 esc()
|
// · 提示词是用户输入,往 HTML 里插必须走 esc()
|
||||||
var persona = { items: [], editing: null, defaultPrompt: '' };
|
var persona = { items: [], editing: null, defaultPrompt: '', avatar: '', clearAvatar: false };
|
||||||
|
|
||||||
function personaOpen() {
|
function personaOpen() {
|
||||||
$('persona-cfg').classList.add('open');
|
$('persona-cfg').classList.add('open');
|
||||||
@@ -2515,11 +2578,73 @@
|
|||||||
? (existing.systemPrompt || '')
|
? (existing.systemPrompt || '')
|
||||||
: (persona.defaultPrompt || '');
|
: (persona.defaultPrompt || '');
|
||||||
$('persona-f-hint').textContent = '人设 + 运行规则都写这里。日期由服务端自动补,不用写。';
|
$('persona-f-hint').textContent = '人设 + 运行规则都写这里。日期由服务端自动补,不用写。';
|
||||||
|
|
||||||
|
// 头像:回填。已有头像时显示预览 + 「移除」按钮
|
||||||
|
persona.avatar = existing ? (existing.avatar || '') : '';
|
||||||
|
persona.clearAvatar = false;
|
||||||
|
personaRenderAvatar();
|
||||||
|
|
||||||
personaCheckPrompt();
|
personaCheckPrompt();
|
||||||
$('persona-edit').classList.add('open');
|
$('persona-edit').classList.add('open');
|
||||||
$('persona-f-name').focus();
|
$('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() {
|
function personaCloseEditor() {
|
||||||
$('persona-edit').classList.remove('open');
|
$('persona-edit').classList.remove('open');
|
||||||
persona.editing = null;
|
persona.editing = null;
|
||||||
@@ -2559,6 +2684,14 @@
|
|||||||
if (!prompt) { toast('提示词不能为空', true); return; }
|
if (!prompt) { toast('提示词不能为空', true); return; }
|
||||||
|
|
||||||
var payload = { name: name, systemPrompt: prompt };
|
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 isEdit = !!persona.editing;
|
||||||
var req = isEdit
|
var req = isEdit
|
||||||
? request('/ai/personas/' + persona.editing.id, { method: 'PUT', body: JSON.stringify(payload) })
|
? request('/ai/personas/' + persona.editing.id, { method: 'PUT', body: JSON.stringify(payload) })
|
||||||
|
|||||||
@@ -339,6 +339,15 @@
|
|||||||
placeholder="比如:你是一个说话简洁的记账助手…"></textarea>
|
placeholder="比如:你是一个说话简洁的记账助手…"></textarea>
|
||||||
<em id="persona-f-hint">人设 + 运行规则都写这里。日期由服务端自动补,不用写。</em>
|
<em id="persona-f-hint">人设 + 运行规则都写这里。日期由服务端自动补,不用写。</em>
|
||||||
</label>
|
</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="cfg-warn" id="persona-warn" style="display:none"></div>
|
||||||
<div class="dialog-foot">
|
<div class="dialog-foot">
|
||||||
<button id="persona-restore">恢复默认</button>
|
<button id="persona-restore">恢复默认</button>
|
||||||
|
|||||||
@@ -38,12 +38,15 @@ import static org.mockito.Mockito.when;
|
|||||||
class AiPersonaServiceTest {
|
class AiPersonaServiceTest {
|
||||||
|
|
||||||
private AiPersonaMapper mapper;
|
private AiPersonaMapper mapper;
|
||||||
|
private AvatarService avatarService;
|
||||||
private AiPersonaService service;
|
private AiPersonaService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
mapper = mock(AiPersonaMapper.class);
|
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
|
@Test
|
||||||
@DisplayName("空人设退到内置默认,而不是让 AI 裸奔")
|
@DisplayName("空人设退到内置默认,而不是让 AI 裸奔")
|
||||||
void emptyPersonaFallsBackToDefault() {
|
void emptyPersonaFallsBackToDefault() {
|
||||||
EffectivePersona empty = new EffectivePersona(" ", "空的", false);
|
EffectivePersona empty = new EffectivePersona(" ", "空的", false, null);
|
||||||
String finalPrompt = service.buildFinalPrompt(empty);
|
String finalPrompt = service.buildFinalPrompt(empty);
|
||||||
|
|
||||||
assertTrue(finalPrompt.contains("必须用工具查询"), "空人设应该退到内置默认");
|
assertTrue(finalPrompt.contains("必须用工具查询"), "空人设应该退到内置默认");
|
||||||
@@ -317,4 +320,148 @@ class AiPersonaServiceTest {
|
|||||||
assertEquals("花花", captor.getValue().getName());
|
assertEquals("花花", captor.getValue().getName());
|
||||||
assertEquals("你是助手。", captor.getValue().getSystemPrompt());
|
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