Compare commits

..

14 Commits

Author SHA1 Message Date
392d9ecfe8 feat(ai-companion): 新增AI角色举报功能
- 新增举报接口 POST /ai-companion/report,支持多选举报类型
- 引入 KeyboardAiCompanionReportService 处理举报业务
- 补充举报相关错误码:类型无效、角色ID为空、类型为空
- 新增实体、DTO、Mapper、Service 及 XML 配置,完成举报数据持久化
2026-01-29 19:38:13 +08:00
6a773ee0ca fix(service): 修复聊天消息排序逻辑
在分页查询消息时,先按时间升序再按ID升序,确保顺序稳定一致
2026-01-29 14:32:16 +08:00
7d23b6be0f fix(service): 仅返回活跃会话中的聊天角色列表 2026-01-28 20:53:47 +08:00
408d4d4bc1 fix(service): 限制聊天查询仅活跃会话 2026-01-28 18:06:51 +08:00
ecab353802 feat(chat): 新增重置会话接口并优化主键策略
- ChatController 增加 /session/reset 端点,支持用户主动重置与 AI 角色的会话
- 会话重置逻辑:将当前活跃会话置为失效,并创建版本号递增的新会话
- 新增 SessionResetReq DTO 与 ChatSessionVO 返回视图
- KeyboardAiChatSession 主键生成策略由 AUTO 改为 ASSIGN_ID,适配分布式场景
2026-01-28 17:56:31 +08:00
234ea0c241 feat(chat): 新增会话管理支持多轮对话
- 引入 KeyboardAiChatSession 实体及对应 Mapper、Service
- 为 KeyboardAiChatMessage 增加 session_id 字段
- ChatServiceImpl 保存消息时绑定会话,支持按用户+角色获取或创建活跃会话
- 保证同一用户同一角色的连续对话归属同一会话,实现多轮上下文管理
2026-01-28 16:50:27 +08:00
e1aa1ce4e8 feat(service): 新增根据ID获取AI角色详情接口 2026-01-28 15:57:59 +08:00
c8d8046bf4 feat(ai-companion): 新增获取用户聊过天的AI角色列表接口 2026-01-28 15:50:15 +08:00
0e863288c8 feat(ai-companion): 新增获取用户已点赞AI角色列表接口 2026-01-28 15:30:48 +08:00
f28e6b7c39 fix(service): 延后VIP体验次数扣减至AI响应成功后 2026-01-27 21:11:45 +08:00
22e5041447 fix(ai-companion): 修复点赞状态与评论回复展示逻辑
- 分页查询接口新增当前用户点赞状态返回
- CommentVO 新增 replies 与 replyCount 字段支持嵌套回复
- 评论服务支持查询一级评论及其前 999 条回复
- 免登录白名单新增 /ai-companion/comment/page 接口
2026-01-27 19:42:44 +08:00
e68f1bea56 feat(ai-companion): 补充点赞与评论统计字段
- 在 AiCompanionVO 新增 likeCount、commentCount
- 分页接口批量聚合点赞/评论数并填充到 VO
- 减少 N+1 查询,提升列表接口性能
2026-01-27 18:37:47 +08:00
b6d124619e feat(ai-companion): 新增AI角色点赞功能
新增点赞/取消点赞接口,包含实体、Mapper、Service及DTO,支持用户点赞状态切换与异常处理。
2026-01-27 18:33:26 +08:00
6cf0275980 feat(speech): 新增语音转文字功能
新增 Deepgram 集成,支持音频文件上传、格式校验与转写;补充相关错误码并放行 /speech/transcribe 接口
2026-01-27 18:17:36 +08:00
39 changed files with 1751 additions and 55 deletions

View File

@@ -2,6 +2,6 @@
"active": true,
"started_at": "2026-01-26T13:01:18.447Z",
"original_prompt": "刚刚回滚了代码现在AI陪聊角色评论需要使用KeyboardAiCompanionCommentLikeService添加一个评论点赞接口用来记录点赞和取消点赞。 ulw",
"reinforcement_count": 4,
"last_checked_at": "2026-01-26T13:55:34.306Z"
"reinforcement_count": 10,
"last_checked_at": "2026-01-27T11:00:42.142Z"
}

View File

@@ -30,6 +30,7 @@ public enum ErrorCode {
CHAT_SAVE_DATA_EMPTY(40010, "保存数据不能为空"),
COMPANION_MESSAGE_EMPTY(40011, "消息内容不能为空"),
COMPANION_ID_EMPTY(40012, "AI陪聊角色ID不能为空"),
COMPANION_NOT_FOUND(40019, "AI陪聊角色不存在"),
COMMENT_CONTENT_EMPTY(40013, "评论内容不能为空"),
COMMENT_NOT_FOUND(40014, "评论不存在"),
COMMENT_ID_EMPTY(40015, "评论ID不能为空"),
@@ -70,7 +71,14 @@ public enum ErrorCode {
INVITE_CODE_ALREADY_BOUND(50028, "您已绑定过邀请码,无法重复绑定"),
INVITE_CODE_CANNOT_BIND_SELF(50029, "不能绑定自己的邀请码"),
RECEIPT_ALREADY_PROCESSED(50027, "收据已处理"),
VIP_TRIAL_LIMIT_REACHED(50030, "今日体验次数已达上限,请开通会员");
VIP_TRIAL_LIMIT_REACHED(50030, "今日体验次数已达上限,请开通会员"),
AUDIO_FILE_EMPTY(40016, "音频文件不能为空"),
AUDIO_FILE_TOO_LARGE(40017, "音频文件过大"),
AUDIO_FORMAT_NOT_SUPPORTED(40018, "音频格式不支持"),
STT_SERVICE_ERROR(50031, "语音转文字服务异常"),
REPORT_TYPE_INVALID(40020, "举报类型无效"),
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
REPORT_TYPE_EMPTY(40022, "举报类型不能为空");
/**
* 状态码

View File

@@ -0,0 +1,34 @@
package com.yolo.keyborad.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Deepgram STT 配置
*
* @author ziin
*/
@Data
@Component
@ConfigurationProperties(prefix = "deepgram")
public class DeepgramProperties {
/** API Key */
private String apiKey;
/** 基础 URL */
private String baseUrl = "https://api.deepgram.com/v1";
/** 模型 ID */
private String model = "nova-2";
/** 默认语言 */
private String language = "en";
/** 智能格式化 */
private Boolean smartFormat = true;
/** 添加标点符号 */
private Boolean punctuate = true;
}

View File

@@ -114,7 +114,10 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/chat/audio/*",
"/ai-companion/page",
"/chat/history",
"/ai-companion/comment/add"
"/ai-companion/comment/add",
"/speech/transcribe",
"/ai-companion/comment/page",
"/ai-companion/liked"
};
}
@Bean

View File

@@ -1,10 +1,17 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.PageDTO;
import com.yolo.keyborad.model.dto.companion.CompanionLikeReq;
import com.yolo.keyborad.model.dto.companion.CompanionReportReq;
import com.yolo.keyborad.model.vo.AiCompanionVO;
import com.yolo.keyborad.service.KeyboardAiCompanionLikeService;
import com.yolo.keyborad.service.KeyboardAiCompanionReportService;
import com.yolo.keyborad.service.KeyboardAiCompanionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -12,6 +19,8 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/*
* @author: ziin
* @date: 2026/1/26
@@ -25,10 +34,64 @@ public class AiCompanionController {
@Resource
private KeyboardAiCompanionService aiCompanionService;
@Resource
private KeyboardAiCompanionLikeService aiCompanionLikeService;
@Resource
private KeyboardAiCompanionReportService aiCompanionReportService;
@PostMapping("/page")
@Operation(summary = "分页查询AI陪聊角色", description = "分页查询已上线的AI陪聊角色列表")
@Operation(summary = "分页查询AI陪聊角色", description = "分页查询已上线的AI陪聊角色列表,包含点赞数、评论数和当前用户点赞状态")
public BaseResponse<IPage<AiCompanionVO>> pageList(@RequestBody PageDTO pageDTO) {
IPage<AiCompanionVO> result = aiCompanionService.pageList(pageDTO.getPageNum(), pageDTO.getPageSize());
Long userId = StpUtil.getLoginIdAsLong();
IPage<AiCompanionVO> result = aiCompanionService.pageListWithLikeStatus(userId, pageDTO.getPageNum(), pageDTO.getPageSize());
return ResultUtils.success(result);
}
@PostMapping("/like")
@Operation(summary = "点赞/取消点赞AI角色", description = "对AI角色进行点赞或取消点赞操作返回true表示点赞成功false表示取消点赞成功")
public BaseResponse<Boolean> toggleLike(@RequestBody CompanionLikeReq req) {
if (req.getCompanionId() == null) {
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
}
Long userId = StpUtil.getLoginIdAsLong();
boolean result = aiCompanionLikeService.toggleLike(userId, req.getCompanionId());
return ResultUtils.success(result);
}
@GetMapping("/liked")
@Operation(summary = "获取当前用户点赞过的AI角色列表", description = "查询当前用户点赞过的所有AI角色返回角色详细信息")
public BaseResponse<List<AiCompanionVO>> getLikedCompanions() {
Long userId = StpUtil.getLoginIdAsLong();
List<AiCompanionVO> result = aiCompanionService.getLikedCompanions(userId);
return ResultUtils.success(result);
}
@GetMapping("/chatted")
@Operation(summary = "获取当前用户聊过天的AI角色列表", description = "查询当前用户聊过天的所有AI角色返回角色详细信息")
public BaseResponse<List<AiCompanionVO>> getChattedCompanions() {
Long userId = StpUtil.getLoginIdAsLong();
List<AiCompanionVO> result = aiCompanionService.getChattedCompanions(userId);
return ResultUtils.success(result);
}
@GetMapping("/{companionId}")
@Operation(summary = "根据ID获取AI角色详情", description = "根据AI角色ID查询角色详细信息包含点赞数、评论数和当前用户点赞状态")
public BaseResponse<AiCompanionVO> getCompanionById(@PathVariable Long companionId) {
if (companionId == null) {
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
}
Long userId = StpUtil.getLoginIdAsLong();
AiCompanionVO result = aiCompanionService.getCompanionById(userId, companionId);
return ResultUtils.success(result);
}
@PostMapping("/report")
@Operation(summary = "举报AI角色", description = "举报AI角色支持多种举报类型可多选1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他")
public BaseResponse<Long> reportCompanion(@RequestBody CompanionReportReq req) {
Long userId = StpUtil.getLoginIdAsLong();
Long reportId = aiCompanionReportService.reportCompanion(userId, req);
return ResultUtils.success(reportId);
}
}

View File

@@ -14,12 +14,15 @@ import com.yolo.keyborad.model.dto.chat.ChatMessageReq;
import com.yolo.keyborad.model.dto.chat.ChatReq;
import com.yolo.keyborad.model.dto.chat.ChatSaveReq;
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
import com.yolo.keyborad.model.dto.chat.SessionResetReq;
import com.yolo.keyborad.model.vo.AudioTaskVO;
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
import com.yolo.keyborad.model.vo.ChatMessageVO;
import com.yolo.keyborad.model.vo.ChatSessionVO;
import com.yolo.keyborad.model.vo.ChatVoiceVO;
import com.yolo.keyborad.service.ChatService;
import com.yolo.keyborad.service.KeyboardAiChatMessageService;
import com.yolo.keyborad.service.KeyboardAiChatSessionService;
import com.yolo.keyborad.service.impl.QdrantVectorService;
import io.qdrant.client.grpc.JsonWithInt;
import io.swagger.v3.oas.annotations.Operation;
@@ -55,6 +58,9 @@ public class ChatController {
@Resource
private KeyboardAiChatMessageService aiChatMessageService;
@Resource
private KeyboardAiChatSessionService aiChatSessionService;
@PostMapping("/message")
@Operation(summary = "同步对话", description = "发送消息给大模型,同步返回 AI 响应,异步生成音频")
@@ -131,4 +137,24 @@ public class ChatController {
userId, req.getCompanionId(), req.getPageNum(), req.getPageSize());
return ResultUtils.success(result);
}
@PostMapping("/session/reset")
@Operation(summary = "重置会话", description = "重置与AI角色的聊天会话将当前会话设为不活跃并创建新会话后续聊天记录将绑定到新会话")
public BaseResponse<ChatSessionVO> resetSession(@RequestBody SessionResetReq req) {
if (req.getCompanionId() == null) {
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
}
Long userId = StpUtil.getLoginIdAsLong();
var newSession = aiChatSessionService.resetSession(userId, req.getCompanionId());
ChatSessionVO vo = ChatSessionVO.builder()
.sessionId(newSession.getId())
.companionId(newSession.getCompanionId())
.resetVersion(newSession.getResetVersion())
.createdAt(newSession.getCreatedAt())
.build();
return ResultUtils.success(vo);
}
}

View File

@@ -0,0 +1,34 @@
package com.yolo.keyborad.controller;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.model.vo.SpeechToTextVO;
import com.yolo.keyborad.service.DeepgramService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* 语音服务控制器
*
* @author ziin
*/
@RestController
@Slf4j
@RequestMapping("/speech")
@Tag(name = "语音服务", description = "语音相关功能接口")
public class SpeechController {
@Resource
private DeepgramService deepgramService;
@PostMapping("/transcribe")
@Operation(summary = "语音转文字", description = "上传音频文件并转换为文本")
public BaseResponse<SpeechToTextVO> transcribe(@RequestPart("file") MultipartFile file) {
SpeechToTextVO result = deepgramService.transcribe(file);
return ResultUtils.success(result);
}
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
/*
* @author: ziin
* @date: 2026/1/28 16:20
*/
public interface KeyboardAiChatSessionMapper extends BaseMapper<KeyboardAiChatSession> {
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionLike;
/*
* @author: ziin
* @date: 2026/1/27 18:18
*/
public interface KeyboardAiCompanionLikeMapper extends BaseMapper<KeyboardAiCompanionLike> {
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionReport;
/*
* @author: ziin
* @date: 2026/1/29 16:17
*/
public interface KeyboardAiCompanionReportMapper extends BaseMapper<KeyboardAiCompanionReport> {
}

View File

@@ -0,0 +1,16 @@
package com.yolo.keyborad.model.dto.chat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/28
*/
@Data
@Schema(description = "会话重置请求")
public class SessionResetReq {
@Schema(description = "AI陪聊角色ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long companionId;
}

View File

@@ -0,0 +1,16 @@
package com.yolo.keyborad.model.dto.companion;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/27
*/
@Data
@Schema(description = "AI角色点赞请求")
public class CompanionLikeReq {
@Schema(description = "AI角色ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long companionId;
}

View File

@@ -0,0 +1,30 @@
package com.yolo.keyborad.model.dto.companion;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/*
* @author: ziin
* @date: 2026/1/29
*/
@Data
@Schema(description = "AI角色举报请求")
public class CompanionReportReq {
@Schema(description = "AI角色ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long companionId;
@Schema(description = "举报类型列表1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他,支持多选", requiredMode = Schema.RequiredMode.REQUIRED)
private List<Short> reportTypes;
@Schema(description = "详细描述")
private String reportDesc;
@Schema(description = "聊天上下文快照JSON")
private String chatContext;
@Schema(description = "图片证据URL")
private String evidenceImageUrl;
}

View File

@@ -75,4 +75,8 @@ public class KeyboardAiChatMessage {
@TableField(value = "created_at")
@Schema(description="消息创建时间")
private Date createdAt;
@TableField(value = "session_id")
@Schema(description = "会话Id")
private Long sessionId;
}

View File

@@ -0,0 +1,71 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/28 16:20
*/
/**
* 用户与AI陪伴角色的聊天会话表用于支持聊天重置与关系重启
*/
@Schema(description="用户与AI陪伴角色的聊天会话表用于支持聊天重置与关系重启")
@Data
@TableName(value = "keyboard_ai_chat_session")
public class KeyboardAiChatSession {
/**
* 聊天会话唯一ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="聊天会话唯一ID")
private Long id;
/**
* 用户ID
*/
@TableField(value = "user_id")
@Schema(description="用户ID")
private Long userId;
/**
* 陪伴角色ID
*/
@TableField(value = "companion_id")
@Schema(description="陪伴角色ID")
private Long companionId;
/**
* 会话重置版本号,用于标识第几次重新开始陪伴关系
*/
@TableField(value = "reset_version")
@Schema(description="会话重置版本号,用于标识第几次重新开始陪伴关系")
private Integer resetVersion;
/**
* 是否为当前活跃会话true=当前使用中)
*/
@TableField(value = "is_active")
@Schema(description="是否为当前活跃会话true=当前使用中)")
private Boolean isActive;
/**
* 会话创建时间
*/
@TableField(value = "created_at")
@Schema(description="会话创建时间")
private Date createdAt;
/**
* 会话结束时间(用户重置或系统关闭会话时记录)
*/
@TableField(value = "ended_at")
@Schema(description="会话结束时间(用户重置或系统关闭会话时记录)")
private Date endedAt;
}

View File

@@ -0,0 +1,64 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/27 18:18
*/
/**
* 用户对AI陪伴角色的点赞行为记录表用于记录点赞与取消点赞
*/
@Schema(description="用户对AI陪伴角色的点赞行为记录表用于记录点赞与取消点赞")
@Data
@TableName(value = "keyboard_ai_companion_like")
public class KeyboardAiCompanionLike {
/**
* AI角色点赞记录唯一ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="AI角色点赞记录唯一ID")
private Long id;
/**
* 被点赞的AI陪伴角色ID
*/
@TableField(value = "companion_id")
@Schema(description="被点赞的AI陪伴角色ID")
private Long companionId;
/**
* 执行点赞操作的用户ID
*/
@TableField(value = "user_id")
@Schema(description="执行点赞操作的用户ID")
private Long userId;
/**
* 点赞状态1=已点赞0=已取消
*/
@TableField(value = "\"status\"")
@Schema(description="点赞状态1=已点赞0=已取消")
private Short status;
/**
* 首次点赞时间
*/
@TableField(value = "created_at")
@Schema(description="首次点赞时间")
private Date createdAt;
/**
* 点赞状态最近更新时间
*/
@TableField(value = "updated_at")
@Schema(description="点赞状态最近更新时间")
private Date updatedAt;
}

View File

@@ -0,0 +1,99 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/29 16:17
*/
/**
* AI角色举报记录表
*/
@Schema(description="AI角色举报记录表")
@Data
@TableName(value = "keyboard_ai_companion_report")
public class KeyboardAiCompanionReport {
/**
* 举报记录唯一ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="举报记录唯一ID")
private Long id;
/**
* 被举报的AI角色ID逻辑关联 keyboard_ai_companion.id无物理外键
*/
@TableField(value = "companion_id")
@Schema(description="被举报的AI角色ID逻辑关联 keyboard_ai_companion.id无物理外键")
private Long companionId;
/**
* 发起举报的用户ID逻辑关联用户表
*/
@TableField(value = "user_id")
@Schema(description="发起举报的用户ID逻辑关联用户表")
private Long userId;
/**
* 举报类型1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他,多选时逗号分隔
*/
@TableField(value = "report_type")
@Schema(description="举报类型1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他,多选时逗号分隔")
private String reportType;
/**
* 用户填写的详细举报描述
*/
@TableField(value = "report_desc")
@Schema(description="用户填写的详细举报描述")
private String reportDesc;
/**
* 违规现场举报时的聊天上下文快照建议存JSON字符串用于审核取证
*/
@TableField(value = "chat_context")
@Schema(description="违规现场举报时的聊天上下文快照建议存JSON字符串用于审核取证")
private String chatContext;
/**
* 图片证据用户上传的截图URL
*/
@TableField(value = "evidence_image_url")
@Schema(description="图片证据用户上传的截图URL")
private String evidenceImageUrl;
/**
* 处理状态0=待处理, 1=违规确立(已处罚), 2=无效举报/已驳回, 3=已忽略
*/
@TableField(value = "\"status\"")
@Schema(description="处理状态0=待处理, 1=违规确立(已处罚), 2=无效举报/已驳回, 3=已忽略")
private Short status;
/**
* 管理员处理备注(记录处理理由或处罚措施)
*/
@TableField(value = "admin_remark")
@Schema(description="管理员处理备注(记录处理理由或处罚措施)")
private String adminRemark;
/**
* 举报提交时间
*/
@TableField(value = "created_at")
@Schema(description="举报提交时间")
private Date createdAt;
/**
* 最后更新时间
*/
@TableField(value = "updated_at")
@Schema(description="最后更新时间")
private Date updatedAt;
}

View File

@@ -55,6 +55,15 @@ public class AiCompanionVO {
@Schema(description = "开场白音频")
private String prologueAudio;
@Schema(description = "点赞总数")
private Integer likeCount;
@Schema(description = "评论总数")
private Integer commentCount;
@Schema(description = "当前用户是否已点赞")
private Boolean liked;
@Schema(description = "创建时间")
private Date createdAt;
}

View File

@@ -0,0 +1,29 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
/*
* @author: ziin
* @date: 2026/1/28
*/
@Data
@Builder
@Schema(description = "会话信息VO")
public class ChatSessionVO {
@Schema(description = "会话ID")
private Long sessionId;
@Schema(description = "AI陪聊角色ID")
private Long companionId;
@Schema(description = "会话版本号")
private Integer resetVersion;
@Schema(description = "会话创建时间")
private Date createdAt;
}

View File

@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
import java.util.List;
/*
* @author: ziin
@@ -45,4 +46,10 @@ public class CommentVO {
@Schema(description = "评论创建时间")
private Date createdAt;
@Schema(description = "回复列表仅一级评论有值默认返回前3条")
private List<CommentVO> replies;
@Schema(description = "回复总数")
private Integer replyCount;
}

View File

@@ -0,0 +1,32 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 语音转文字响应VO
*
* @author ziin
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "语音转文字响应")
public class SpeechToTextVO {
@Schema(description = "转录文本")
private String transcript;
@Schema(description = "置信度")
private Double confidence;
@Schema(description = "音频时长(秒)")
private Double duration;
@Schema(description = "检测到的语言")
private String detectedLanguage;
}

View File

@@ -0,0 +1,29 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.vo.SpeechToTextVO;
import org.springframework.web.multipart.MultipartFile;
/**
* Deepgram STT 语音转文字服务接口
*
* @author ziin
*/
public interface DeepgramService {
/**
* 将音频文件转换为文字(使用默认语言)
*
* @param audioFile 音频文件
* @return 语音转文字结果
*/
SpeechToTextVO transcribe(MultipartFile audioFile);
/**
* 将音频文件转换为文字(指定语言)
*
* @param audioFile 音频文件
* @param language 语言代码(如 en, zh, ja 等)
* @return 语音转文字结果
*/
SpeechToTextVO transcribe(MultipartFile audioFile, String language);
}

View File

@@ -33,4 +33,12 @@ public interface KeyboardAiChatMessageService extends IService<KeyboardAiChatMes
* @return 聊天记录列表(时间正序)
*/
List<KeyboardAiChatMessage> getRecentMessages(Long userId, Long companionId, int limit);
/**
* 获取用户聊过天的所有AI角色ID列表
*
* @param userId 用户ID
* @return 聊过天的AI角色ID列表按最近聊天时间倒序
*/
List<Long> getChattedCompanionIds(Long userId);
}

View File

@@ -0,0 +1,38 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
import com.baomidou.mybatisplus.extension.service.IService;
/*
* @author: ziin
* @date: 2026/1/28 16:20
*/
public interface KeyboardAiChatSessionService extends IService<KeyboardAiChatSession> {
/**
* 获取用户与AI角色的活跃会话如果不存在则创建新会话
*
* @param userId 用户ID
* @param companionId AI角色ID
* @return 活跃会话
*/
KeyboardAiChatSession getOrCreateActiveSession(Long userId, Long companionId);
/**
* 获取用户与AI角色的活跃会话ID如果不存在则创建新会话
*
* @param userId 用户ID
* @param companionId AI角色ID
* @return 活跃会话ID
*/
Long getOrCreateActiveSessionId(Long userId, Long companionId);
/**
* 重置会话:将当前活跃会话设为不活跃,并创建新的活跃会话
*
* @param userId 用户ID
* @param companionId AI角色ID
* @return 新创建的活跃会话
*/
KeyboardAiChatSession resetSession(Long userId, Long companionId);
}

View File

@@ -0,0 +1,49 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionLike;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.List;
import java.util.Set;
/*
* @author: ziin
* @date: 2026/1/27 18:18
*/
public interface KeyboardAiCompanionLikeService extends IService<KeyboardAiCompanionLike> {
/**
* 点赞/取消点赞AI角色
*
* @param userId 用户ID
* @param companionId AI角色ID
* @return true=点赞成功false=取消点赞成功
*/
boolean toggleLike(Long userId, Long companionId);
/**
* 检查用户是否已点赞某AI角色
*
* @param userId 用户ID
* @param companionId AI角色ID
* @return true=已点赞false=未点赞
*/
boolean hasLiked(Long userId, Long companionId);
/**
* 批量获取用户已点赞的AI角色ID列表
*
* @param userId 用户ID
* @param companionIds AI角色ID列表
* @return 已点赞的AI角色ID集合
*/
Set<Long> getLikedCompanionIds(Long userId, List<Long> companionIds);
/**
* 获取用户点赞过的所有AI角色ID列表
*
* @param userId 用户ID
* @return 已点赞的AI角色ID列表
*/
List<Long> getAllLikedCompanionIds(Long userId);
}

View File

@@ -0,0 +1,21 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.dto.companion.CompanionReportReq;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionReport;
import com.baomidou.mybatisplus.extension.service.IService;
/*
* @author: ziin
* @date: 2026/1/29 16:17
*/
public interface KeyboardAiCompanionReportService extends IService<KeyboardAiCompanionReport>{
/**
* 举报AI角色
* @param userId 用户ID
* @param req 举报请求
* @return 举报记录ID
*/
Long reportCompanion(Long userId, CompanionReportReq req);
}

View File

@@ -5,6 +5,8 @@ import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.vo.AiCompanionVO;
import java.util.List;
/*
* @author: ziin
* @date: 2026/1/26 13:51
@@ -20,6 +22,16 @@ public interface KeyboardAiCompanionService extends IService<KeyboardAiCompanion
*/
IPage<AiCompanionVO> pageList(Integer pageNum, Integer pageSize);
/**
* 分页查询已上线的AI陪聊角色带当前用户点赞状态
*
* @param userId 当前用户ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 分页结果
*/
IPage<AiCompanionVO> pageListWithLikeStatus(Long userId, Integer pageNum, Integer pageSize);
/**
* 根据AI人设ID获取系统提示词
*
@@ -27,4 +39,29 @@ public interface KeyboardAiCompanionService extends IService<KeyboardAiCompanion
* @return 系统提示词
*/
String getSystemPromptById(Long companionId);
/**
* 获取用户点赞过的AI角色列表
*
* @param userId 用户ID
* @return 点赞过的AI角色列表
*/
List<AiCompanionVO> getLikedCompanions(Long userId);
/**
* 获取用户聊过天的AI角色列表
*
* @param userId 用户ID
* @return 聊过天的AI角色列表
*/
List<AiCompanionVO> getChattedCompanions(Long userId);
/**
* 根据ID获取AI角色详情带点赞数、评论数和当前用户点赞状态
*
* @param userId 当前用户ID
* @param companionId AI角色ID
* @return AI角色详情
*/
AiCompanionVO getCompanionById(Long userId, Long companionId);
}

View File

@@ -89,6 +89,9 @@ public class ChatServiceImpl implements ChatService {
@Resource
private KeyboardAiChatMessageService aiChatMessageService;
@Resource
private KeyboardAiChatSessionService aiChatSessionService;
@Resource
private ElevenLabsService elevenLabsService;
@@ -376,41 +379,29 @@ public class ChatServiceImpl implements ChatService {
public ChatMessageVO message(String content, String userId, Long companionId) {
log.info("同步对话请求, userId: {}, companionId: {}, content: {}", userId, companionId, content);
// ============ VIP等级检查 ============
// ============ VIP等级检查(先检查,不增加次数) ============
AppConfig appConfig = cfgHolder.getRef().get();
KeyboardUser user = userService.getById(Long.parseLong(userId));
// 获取VIP等级null视为0
int vipLevel = user != null && user.getVipLevel() != null ? user.getVipLevel() : 0;
// 如果VIP等级 <= 1需要限制每日体验次数
if (vipLevel <= 1) {
Integer dailyLimit = appConfig.getUserRegisterProperties().getVipFreeTrialTalk();
String redisKey = CHAT_DAILY_LIMIT_PREFIX + userId;
// 记录是否需要扣减体验次数VIP等级 <= 1 的用户需要扣减)
boolean needDeductQuota = vipLevel <= 1;
String redisKey = CHAT_DAILY_LIMIT_PREFIX + userId;
Integer dailyLimit = appConfig.getUserRegisterProperties().getVipFreeTrialTalk();
// 如果VIP等级 <= 1先检查每日体验次数是否用完
if (needDeductQuota) {
// 获取当前使用次数
String countStr = stringRedisTemplate.opsForValue().get(redisKey);
int currentCount = countStr != null ? Integer.parseInt(countStr) : 0;
// 检查是否超出限制
// 检查是否超出限制,超出直接返回
if (currentCount >= dailyLimit) {
log.warn("用户 {} VIP等级 {} 已达到每日体验次数限制 {}", userId, vipLevel, dailyLimit);
throw new BusinessException(ErrorCode.VIP_TRIAL_LIMIT_REACHED);
}
// 增加使用次数
Long newCount = stringRedisTemplate.opsForValue().increment(redisKey);
// 设置到午夜过期(只有第一次设置时需要设置过期时间)
if (newCount != null && newCount == 1) {
// 计算到今天午夜的剩余秒数
LocalDateTime now = LocalDateTime.now(ZoneId.of("America/New_York"));
LocalDateTime midnight = LocalDateTime.of(LocalDate.now(ZoneId.of("America/New_York")).plusDays(1), LocalTime.MIDNIGHT);
long secondsUntilMidnight = ChronoUnit.SECONDS.between(now, midnight);
stringRedisTemplate.expire(redisKey, secondsUntilMidnight, TimeUnit.SECONDS);
}
log.info("用户 {} VIP等级 {} 今日已使用 {}/{} 次", userId, vipLevel, newCount, dailyLimit);
}
long startTime = System.currentTimeMillis();
@@ -431,6 +422,22 @@ public class ChatServiceImpl implements ChatService {
// 保存用户消息和AI响应到聊天记录
saveChatMessages(Long.parseLong(userId), companionId, content, response);
// ============ 成功后扣减体验次数 ============
if (needDeductQuota) {
Long newCount = stringRedisTemplate.opsForValue().increment(redisKey);
// 设置到午夜过期(只有第一次设置时需要设置过期时间)
if (newCount != null && newCount == 1) {
// 计算到今天午夜的剩余秒数
LocalDateTime now = LocalDateTime.now(ZoneId.of("America/New_York"));
LocalDateTime midnight = LocalDateTime.of(LocalDate.now(ZoneId.of("America/New_York")).plusDays(1), LocalTime.MIDNIGHT);
long secondsUntilMidnight = ChronoUnit.SECONDS.between(now, midnight);
stringRedisTemplate.expire(redisKey, secondsUntilMidnight, TimeUnit.SECONDS);
}
log.info("用户 {} VIP等级 {} 今日已使用 {}/{} 次", userId, vipLevel, newCount, dailyLimit);
}
// 生成音频任务 ID
String audioId = UUID.randomUUID().toString().replace("-", "");
@@ -453,10 +460,14 @@ public class ChatServiceImpl implements ChatService {
private void saveChatMessages(Long userId, Long companionId, String userContent, String aiResponse) {
Date now = new Date();
// 获取或创建活跃会话
Long sessionId = aiChatSessionService.getOrCreateActiveSessionId(userId, companionId);
// 保存用户消息 (sender=1)
KeyboardAiChatMessage userMessage = new KeyboardAiChatMessage();
userMessage.setUserId(userId);
userMessage.setCompanionId(companionId);
userMessage.setSessionId(sessionId);
userMessage.setSender((short) 1);
userMessage.setContent(userContent);
userMessage.setCreatedAt(now);
@@ -465,13 +476,14 @@ public class ChatServiceImpl implements ChatService {
KeyboardAiChatMessage aiMessage = new KeyboardAiChatMessage();
aiMessage.setUserId(userId);
aiMessage.setCompanionId(companionId);
aiMessage.setSessionId(sessionId);
aiMessage.setSender((short) 2);
aiMessage.setContent(aiResponse);
aiMessage.setCreatedAt(now);
// 批量保存
aiChatMessageService.saveBatch(List.of(userMessage, aiMessage));
log.info("聊天记录保存成功, userId: {}, companionId: {}", userId, companionId);
log.info("聊天记录保存成功, userId: {}, companionId: {}, sessionId: {}", userId, companionId, sessionId);
}
/**

View File

@@ -0,0 +1,182 @@
package com.yolo.keyborad.service.impl;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.config.DeepgramProperties;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.vo.SpeechToTextVO;
import com.yolo.keyborad.service.DeepgramService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* Deepgram STT 语音转文字服务实现
* 参考: https://developers.deepgram.com/docs/getting-started-with-pre-recorded-audio
*
* @author ziin
*/
@Service
@Slf4j
public class DeepgramServiceImpl implements DeepgramService {
@Resource
private DeepgramProperties deepgramProperties;
@Resource
private RestClient restClient;
// 支持的音频MIME类型
private static final List<String> ALLOWED_AUDIO_TYPES = Arrays.asList(
"audio/wav", "audio/wave",
"audio/mp3", "audio/mpeg",
"audio/webm",
"audio/ogg",
"audio/flac",
"audio/m4a"
);
// 最大文件大小20MB
private static final long MAX_FILE_SIZE = 20 * 1024 * 1024;
@Override
public SpeechToTextVO transcribe(MultipartFile audioFile) {
return transcribe(audioFile, deepgramProperties.getLanguage());
}
@Override
public SpeechToTextVO transcribe(MultipartFile audioFile, String language) {
// 1. 参数校验
validateAudioFile(audioFile);
if (StrUtil.isBlank(language)) {
language = deepgramProperties.getLanguage();
}
// 2. 获取音频Content-Type
String contentType = audioFile.getContentType();
if (StrUtil.isBlank(contentType) || !ALLOWED_AUDIO_TYPES.contains(contentType)) {
log.warn("不支持的音频格式: {}", contentType);
throw new BusinessException(ErrorCode.AUDIO_FORMAT_NOT_SUPPORTED);
}
// 3. 构建请求URL
String requestUrl = buildRequestUrl(language);
log.info("调用 Deepgram STT API, language: {}, contentType: {}, 文件大小: {} bytes",
language, contentType, audioFile.getSize());
long startTime = System.currentTimeMillis();
try {
// 4. 发送请求
byte[] audioBytes = audioFile.getBytes();
String responseJson = restClient.post()
.uri(requestUrl)
.contentType(MediaType.parseMediaType(contentType))
.header("Authorization", "Token " + deepgramProperties.getApiKey())
.body(audioBytes)
.retrieve()
.body(String.class);
long duration = System.currentTimeMillis() - startTime;
log.info("Deepgram STT API 响应成功, 耗时: {}ms", duration);
// 5. 解析响应
return parseResponse(responseJson);
} catch (IOException e) {
log.error("读取音频文件失败", e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "音频文件读取失败: " + e.getMessage());
} catch (Exception e) {
log.error("调用 Deepgram STT API 发生异常", e);
throw new BusinessException(ErrorCode.STT_SERVICE_ERROR, "语音转文字服务异常: " + e.getMessage());
}
}
/**
* 校验音频文件
*/
private void validateAudioFile(MultipartFile audioFile) {
if (audioFile == null || audioFile.isEmpty()) {
throw new BusinessException(ErrorCode.AUDIO_FILE_EMPTY);
}
if (audioFile.getSize() > MAX_FILE_SIZE) {
throw new BusinessException(ErrorCode.AUDIO_FILE_TOO_LARGE);
}
}
/**
* 构建请求URL
*/
private String buildRequestUrl(String language) {
StringBuilder url = new StringBuilder(deepgramProperties.getBaseUrl());
url.append("/listen");
// 添加查询参数
url.append("?model=").append(deepgramProperties.getModel());
url.append("&language=").append(language);
if (deepgramProperties.getSmartFormat()) {
url.append("&smart_format=true");
}
if (deepgramProperties.getPunctuate()) {
url.append("&punctuate=true");
}
return url.toString();
}
/**
* 解析响应JSON
*/
private SpeechToTextVO parseResponse(String responseJson) {
JSONObject jsonResponse = JSONObject.parseObject(responseJson);
// 解析 metadata
JSONObject metadata = jsonResponse.getJSONObject("metadata");
Double duration = metadata != null ? metadata.getDouble("duration") : null;
// 解析 results
JSONObject results = jsonResponse.getJSONObject("results");
if (results == null) {
throw new BusinessException(ErrorCode.STT_SERVICE_ERROR, "响应格式错误: 缺少 results");
}
JSONArray channels = results.getJSONArray("channels");
if (channels == null || channels.isEmpty()) {
throw new BusinessException(ErrorCode.STT_SERVICE_ERROR, "响应格式错误: 缺少 channels");
}
JSONObject channel = channels.getJSONObject(0);
JSONArray alternatives = channel.getJSONArray("alternatives");
if (alternatives == null || alternatives.isEmpty()) {
throw new BusinessException(ErrorCode.STT_SERVICE_ERROR, "响应格式错误: 缺少 alternatives");
}
JSONObject alternative = alternatives.getJSONObject(0);
String transcript = alternative.getString("transcript");
Double confidence = alternative.getDouble("confidence");
String detectedLanguage = channel.getString("detected_language");
log.info("转录成功, 文本长度: {}, 置信度: {}, 检测语言: {}",
transcript != null ? transcript.length() : 0, confidence, detectedLanguage);
return SpeechToTextVO.builder()
.transcript(transcript)
.confidence(confidence)
.duration(duration)
.detectedLanguage(detectedLanguage)
.build();
}
}

View File

@@ -7,8 +7,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardAiChatMessageMapper;
import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
import com.yolo.keyborad.service.KeyboardAiChatMessageService;
import com.yolo.keyborad.service.KeyboardAiChatSessionService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Collections;
@@ -21,22 +24,52 @@ import java.util.List;
@Service
public class KeyboardAiChatMessageServiceImpl extends ServiceImpl<KeyboardAiChatMessageMapper, KeyboardAiChatMessage> implements KeyboardAiChatMessageService {
@Resource
private KeyboardAiChatSessionService sessionService;
@Override
public IPage<ChatMessageHistoryVO> pageHistory(Long userId, Long companionId, Integer pageNum, Integer pageSize) {
// 获取当前活跃会话
LambdaQueryWrapper<KeyboardAiChatSession> sessionWrapper = new LambdaQueryWrapper<>();
sessionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
.eq(KeyboardAiChatSession::getCompanionId, companionId)
.eq(KeyboardAiChatSession::getIsActive, true);
KeyboardAiChatSession activeSession = sessionService.getOne(sessionWrapper);
// 如果没有活跃会话,返回空分页
if (activeSession == null) {
return new Page<ChatMessageHistoryVO>(pageNum, pageSize).setRecords(Collections.emptyList());
}
Page<KeyboardAiChatMessage> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<KeyboardAiChatMessage> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiChatMessage::getUserId, userId)
.eq(KeyboardAiChatMessage::getCompanionId, companionId)
.orderByDesc(KeyboardAiChatMessage::getCreatedAt);
.eq(KeyboardAiChatMessage::getSessionId, activeSession.getId())
.orderByAsc(KeyboardAiChatMessage::getCreatedAt)
.orderByAsc(KeyboardAiChatMessage::getId);
IPage<KeyboardAiChatMessage> entityPage = this.page(page, queryWrapper);
return entityPage.convert(entity -> BeanUtil.copyProperties(entity, ChatMessageHistoryVO.class));
}
@Override
public List<KeyboardAiChatMessage> getRecentMessages(Long userId, Long companionId, int limit) {
// 获取当前活跃会话
LambdaQueryWrapper<KeyboardAiChatSession> sessionWrapper = new LambdaQueryWrapper<>();
sessionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
.eq(KeyboardAiChatSession::getCompanionId, companionId)
.eq(KeyboardAiChatSession::getIsActive, true);
KeyboardAiChatSession activeSession = sessionService.getOne(sessionWrapper);
// 如果没有活跃会话,返回空列表
if (activeSession == null) {
return Collections.emptyList();
}
LambdaQueryWrapper<KeyboardAiChatMessage> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiChatMessage::getUserId, userId)
.eq(KeyboardAiChatMessage::getCompanionId, companionId)
.eq(KeyboardAiChatMessage::getSessionId, activeSession.getId())
.orderByDesc(KeyboardAiChatMessage::getCreatedAt)
.last("LIMIT " + limit);
List<KeyboardAiChatMessage> messages = this.list(queryWrapper);
@@ -44,4 +77,37 @@ public class KeyboardAiChatMessageServiceImpl extends ServiceImpl<KeyboardAiChat
Collections.reverse(messages);
return messages;
}
@Override
public List<Long> getChattedCompanionIds(Long userId) {
// 1. 查询用户所有活跃会话
LambdaQueryWrapper<KeyboardAiChatSession> sessionWrapper = new LambdaQueryWrapper<>();
sessionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
.eq(KeyboardAiChatSession::getIsActive, true);
List<KeyboardAiChatSession> activeSessions = sessionService.list(sessionWrapper);
// 2. 如果没有活跃会话,返回空列表
if (activeSessions == null || activeSessions.isEmpty()) {
return Collections.emptyList();
}
// 3. 提取活跃会话的 sessionId 列表
List<Long> activeSessionIds = activeSessions.stream()
.map(KeyboardAiChatSession::getId)
.collect(java.util.stream.Collectors.toList());
// 4. 查询这些会话中的消息,获取 companionId按最近聊天时间倒序
LambdaQueryWrapper<KeyboardAiChatMessage> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiChatMessage::getUserId, userId)
.in(KeyboardAiChatMessage::getSessionId, activeSessionIds)
.orderByDesc(KeyboardAiChatMessage::getCreatedAt);
List<KeyboardAiChatMessage> messages = this.list(queryWrapper);
// 5. 去重并保持顺序(按最近聊天时间)
return messages.stream()
.map(KeyboardAiChatMessage::getCompanionId)
.distinct()
.collect(java.util.stream.Collectors.toList());
}
}

View File

@@ -0,0 +1,97 @@
package com.yolo.keyborad.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardAiChatSessionMapper;
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
import com.yolo.keyborad.service.KeyboardAiChatSessionService;
import java.util.Date;
/*
* @author: ziin
* @date: 2026/1/28 16:20
*/
@Service
public class KeyboardAiChatSessionServiceImpl extends ServiceImpl<KeyboardAiChatSessionMapper, KeyboardAiChatSession> implements KeyboardAiChatSessionService {
@Override
public KeyboardAiChatSession getOrCreateActiveSession(Long userId, Long companionId) {
// 查询当前活跃会话
LambdaQueryWrapper<KeyboardAiChatSession> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiChatSession::getUserId, userId)
.eq(KeyboardAiChatSession::getCompanionId, companionId)
.eq(KeyboardAiChatSession::getIsActive, true);
KeyboardAiChatSession activeSession = this.getOne(queryWrapper);
if (activeSession != null) {
return activeSession;
}
// 不存在活跃会话,创建新会话
// 先查询该用户与该角色的最大版本号
LambdaQueryWrapper<KeyboardAiChatSession> maxVersionWrapper = new LambdaQueryWrapper<>();
maxVersionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
.eq(KeyboardAiChatSession::getCompanionId, companionId)
.orderByDesc(KeyboardAiChatSession::getResetVersion)
.last("LIMIT 1");
KeyboardAiChatSession lastSession = this.getOne(maxVersionWrapper);
int newVersion = lastSession != null ? lastSession.getResetVersion() + 1 : 1;
// 创建新会话
KeyboardAiChatSession newSession = new KeyboardAiChatSession();
newSession.setUserId(userId);
newSession.setCompanionId(companionId);
newSession.setResetVersion(newVersion);
newSession.setIsActive(true);
newSession.setCreatedAt(new Date());
this.save(newSession);
return newSession;
}
@Override
public Long getOrCreateActiveSessionId(Long userId, Long companionId) {
return getOrCreateActiveSession(userId, companionId).getId();
}
@Override
public KeyboardAiChatSession resetSession(Long userId, Long companionId) {
Date now = new Date();
// 查询当前活跃会话
LambdaQueryWrapper<KeyboardAiChatSession> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiChatSession::getUserId, userId)
.eq(KeyboardAiChatSession::getCompanionId, companionId)
.eq(KeyboardAiChatSession::getIsActive, true);
KeyboardAiChatSession activeSession = this.getOne(queryWrapper);
// 如果存在活跃会话,将其设为不活跃
if (activeSession != null) {
activeSession.setIsActive(false);
activeSession.setEndedAt(now);
this.updateById(activeSession);
}
// 查询该用户与该角色的最大版本号
LambdaQueryWrapper<KeyboardAiChatSession> maxVersionWrapper = new LambdaQueryWrapper<>();
maxVersionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
.eq(KeyboardAiChatSession::getCompanionId, companionId)
.orderByDesc(KeyboardAiChatSession::getResetVersion)
.last("LIMIT 1");
KeyboardAiChatSession lastSession = this.getOne(maxVersionWrapper);
int newVersion = lastSession != null ? lastSession.getResetVersion() + 1 : 1;
// 创建新的活跃会话
KeyboardAiChatSession newSession = new KeyboardAiChatSession();
newSession.setUserId(userId);
newSession.setCompanionId(companionId);
newSession.setResetVersion(newVersion);
newSession.setIsActive(true);
newSession.setCreatedAt(now);
this.save(newSession);
return newSession;
}
}

View File

@@ -14,6 +14,7 @@ import com.yolo.keyborad.service.UserService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
@@ -104,12 +105,51 @@ public class KeyboardAiCompanionCommentServiceImpl extends ServiceImpl<KeyboardA
.orderByDesc(KeyboardAiCompanionComment::getCreatedAt);
IPage<KeyboardAiCompanionComment> entityPage = this.page(page, queryWrapper);
// 获取所有用户ID
List<Long> userIds = entityPage.getRecords().stream()
.map(KeyboardAiCompanionComment::getUserId)
.distinct()
// 获取所有一级评论ID
List<Long> topCommentIds = entityPage.getRecords().stream()
.map(KeyboardAiCompanionComment::getId)
.collect(Collectors.toList());
// 批量查询回复每条一级评论取前3条回复
Map<Long, List<KeyboardAiCompanionComment>> repliesMap = Map.of();
Map<Long, Long> replyCountMap = Map.of();
if (!topCommentIds.isEmpty()) {
// 查询所有回复
LambdaQueryWrapper<KeyboardAiCompanionComment> replyWrapper = new LambdaQueryWrapper<>();
replyWrapper.in(KeyboardAiCompanionComment::getRootId, topCommentIds)
.eq(KeyboardAiCompanionComment::getStatus, 1)
.orderByAsc(KeyboardAiCompanionComment::getCreatedAt);
List<KeyboardAiCompanionComment> allReplies = this.list(replyWrapper);
// 按rootId分组每组取前3条
repliesMap = allReplies.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getRootId));
// 统计回复数量
replyCountMap = repliesMap.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> (long) e.getValue().size()));
// 每组只保留前3条
repliesMap = repliesMap.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().stream().limit(999).collect(Collectors.toList())
));
}
// 收集所有需要查询的用户ID一级评论 + 回复)
List<Long> userIds = new ArrayList<>(entityPage.getRecords().stream()
.map(KeyboardAiCompanionComment::getUserId)
.collect(Collectors.toSet()));
repliesMap.values().stream()
.flatMap(List::stream)
.map(KeyboardAiCompanionComment::getUserId)
.forEach(uid -> {
if (!userIds.contains(uid)) {
userIds.add(uid);
}
});
// 批量查询用户信息
Map<Long, KeyboardUser> userMap = Map.of();
if (!userIds.isEmpty()) {
@@ -117,33 +157,55 @@ public class KeyboardAiCompanionCommentServiceImpl extends ServiceImpl<KeyboardA
userMap = users.stream().collect(Collectors.toMap(KeyboardUser::getId, u -> u));
}
// 获取当前用户已点赞的评论ID
List<Long> commentIds = entityPage.getRecords().stream()
// 收集所有评论ID用于查询点赞状态一级评论 + 回复)
List<Long> allCommentIds = new ArrayList<>(topCommentIds);
repliesMap.values().stream()
.flatMap(List::stream)
.map(KeyboardAiCompanionComment::getId)
.collect(Collectors.toList());
Set<Long> likedCommentIds = commentLikeService.getLikedCommentIds(userId, commentIds);
.forEach(allCommentIds::add);
Set<Long> likedCommentIds = commentLikeService.getLikedCommentIds(userId, allCommentIds);
// 转换为VO
Map<Long, KeyboardUser> finalUserMap = userMap;
return entityPage.convert(entity -> {
CommentVO vo = new CommentVO();
vo.setId(entity.getId());
vo.setCompanionId(entity.getCompanionId());
vo.setUserId(entity.getUserId());
vo.setParentId(entity.getParentId());
vo.setRootId(entity.getRootId());
vo.setContent(entity.getContent());
vo.setLikeCount(entity.getLikeCount());
vo.setCreatedAt(entity.getCreatedAt());
vo.setLiked(likedCommentIds.contains(entity.getId()));
Map<Long, List<KeyboardAiCompanionComment>> finalRepliesMap = repliesMap;
Map<Long, Long> finalReplyCountMap = replyCountMap;
return entityPage.convert(entity -> {
CommentVO vo = convertToVO(entity, finalUserMap, likedCommentIds);
// 填充回复列表
List<KeyboardAiCompanionComment> replies = finalRepliesMap.getOrDefault(entity.getId(), List.of());
List<CommentVO> replyVOs = replies.stream()
.map(reply -> convertToVO(reply, finalUserMap, likedCommentIds))
.collect(Collectors.toList());
vo.setReplies(replyVOs);
vo.setReplyCount(finalReplyCountMap.getOrDefault(entity.getId(), 0L).intValue());
// 填充用户信息
KeyboardUser user = finalUserMap.get(entity.getUserId());
if (user != null) {
vo.setUserName(user.getNickName());
vo.setUserAvatar(user.getAvatarUrl());
}
return vo;
});
}
/**
* 将评论实体转换为VO
*/
private CommentVO convertToVO(KeyboardAiCompanionComment entity, Map<Long, KeyboardUser> userMap, Set<Long> likedCommentIds) {
CommentVO vo = new CommentVO();
vo.setId(entity.getId());
vo.setCompanionId(entity.getCompanionId());
vo.setUserId(entity.getUserId());
vo.setParentId(entity.getParentId());
vo.setRootId(entity.getRootId());
vo.setContent(entity.getContent());
vo.setLikeCount(entity.getLikeCount());
vo.setCreatedAt(entity.getCreatedAt());
vo.setLiked(likedCommentIds.contains(entity.getId()));
// 填充用户信息
KeyboardUser user = userMap.get(entity.getUserId());
if (user != null) {
vo.setUserName(user.getNickName());
vo.setUserAvatar(user.getAvatarUrl());
}
return vo;
}
}

View File

@@ -0,0 +1,115 @@
package com.yolo.keyborad.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardAiCompanionLikeMapper;
import com.yolo.keyborad.mapper.KeyboardAiCompanionMapper;
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionLike;
import com.yolo.keyborad.service.KeyboardAiCompanionLikeService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/*
* @author: ziin
* @date: 2026/1/27 18:18
*/
@Service
public class KeyboardAiCompanionLikeServiceImpl extends ServiceImpl<KeyboardAiCompanionLikeMapper, KeyboardAiCompanionLike> implements KeyboardAiCompanionLikeService {
@Resource
private KeyboardAiCompanionMapper companionMapper;
@Override
public boolean hasLiked(Long userId, Long companionId) {
LambdaQueryWrapper<KeyboardAiCompanionLike> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanionLike::getUserId, userId)
.eq(KeyboardAiCompanionLike::getCompanionId, companionId)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
return this.count(queryWrapper) > 0;
}
@Override
public Set<Long> getLikedCompanionIds(Long userId, List<Long> companionIds) {
if (companionIds == null || companionIds.isEmpty()) {
return new HashSet<>();
}
LambdaQueryWrapper<KeyboardAiCompanionLike> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanionLike::getUserId, userId)
.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
List<KeyboardAiCompanionLike> likes = this.list(queryWrapper);
return likes.stream()
.map(KeyboardAiCompanionLike::getCompanionId)
.collect(Collectors.toSet());
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean toggleLike(Long userId, Long companionId) {
// 检查AI角色是否存在
KeyboardAiCompanion companion = companionMapper.selectById(companionId);
if (companion == null || companion.getStatus() != 1) {
throw new BusinessException(ErrorCode.COMPANION_NOT_FOUND);
}
// 查找现有点赞记录
LambdaQueryWrapper<KeyboardAiCompanionLike> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanionLike::getUserId, userId)
.eq(KeyboardAiCompanionLike::getCompanionId, companionId);
KeyboardAiCompanionLike existingLike = this.getOne(queryWrapper);
Date now = new Date();
boolean isLiked;
if (existingLike != null) {
// 切换点赞状态
if (existingLike.getStatus() == 1) {
// 取消点赞
existingLike.setStatus((short) 0);
existingLike.setUpdatedAt(now);
this.updateById(existingLike);
isLiked = false;
} else {
// 重新点赞
existingLike.setStatus((short) 1);
existingLike.setUpdatedAt(now);
this.updateById(existingLike);
isLiked = true;
}
} else {
// 新增点赞记录
KeyboardAiCompanionLike like = new KeyboardAiCompanionLike();
like.setUserId(userId);
like.setCompanionId(companionId);
like.setStatus((short) 1);
like.setCreatedAt(now);
like.setUpdatedAt(now);
this.save(like);
isLiked = true;
}
return isLiked;
}
@Override
public List<Long> getAllLikedCompanionIds(Long userId) {
LambdaQueryWrapper<KeyboardAiCompanionLike> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanionLike::getUserId, userId)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1)
.select(KeyboardAiCompanionLike::getCompanionId);
List<KeyboardAiCompanionLike> likes = this.list(queryWrapper);
return likes.stream()
.map(KeyboardAiCompanionLike::getCompanionId)
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,77 @@
package com.yolo.keyborad.service.impl;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.companion.CompanionReportReq;
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
import com.yolo.keyborad.service.KeyboardAiCompanionService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardAiCompanionReportMapper;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionReport;
import com.yolo.keyborad.service.KeyboardAiCompanionReportService;
/*
* @author: ziin
* @date: 2026/1/29 16:17
*/
@Service
public class KeyboardAiCompanionReportServiceImpl extends ServiceImpl<KeyboardAiCompanionReportMapper, KeyboardAiCompanionReport> implements KeyboardAiCompanionReportService{
@Resource
private KeyboardAiCompanionService aiCompanionService;
@Override
public Long reportCompanion(Long userId, CompanionReportReq req) {
// 校验 companionId 不为空
if (req.getCompanionId() == null) {
throw new BusinessException(ErrorCode.REPORT_COMPANION_ID_EMPTY);
}
// 校验 reportTypes 不为空
if (req.getReportTypes() == null || req.getReportTypes().isEmpty()) {
throw new BusinessException(ErrorCode.REPORT_TYPE_EMPTY);
}
// 校验每个 reportType 在有效范围内1,2,3,4,5,99
List<Short> validTypes = List.of((short) 1, (short) 2, (short) 3, (short) 4, (short) 5, (short) 99);
for (Short type : req.getReportTypes()) {
if (!validTypes.contains(type)) {
throw new BusinessException(ErrorCode.REPORT_TYPE_INVALID);
}
}
// 校验 AI 角色是否存在
KeyboardAiCompanion companion = aiCompanionService.getById(req.getCompanionId());
if (companion == null) {
throw new BusinessException(ErrorCode.COMPANION_NOT_FOUND);
}
// 创建举报记录
KeyboardAiCompanionReport report = new KeyboardAiCompanionReport();
report.setUserId(userId);
report.setCompanionId(req.getCompanionId());
// 将 List<Short> 转换为逗号分隔的字符串
String reportTypeStr = req.getReportTypes().stream()
.map(String::valueOf)
.collect(Collectors.joining(","));
report.setReportType(reportTypeStr);
report.setReportDesc(req.getReportDesc());
report.setChatContext(req.getChatContext());
report.setEvidenceImageUrl(req.getEvidenceImageUrl());
report.setStatus((short) 0); // 待处理
report.setCreatedAt(new Date());
// 保存并返回 ID
this.save(report);
return report.getId();
}
}

View File

@@ -6,13 +6,24 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionComment;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionLike;
import com.yolo.keyborad.model.vo.AiCompanionVO;
import com.yolo.keyborad.service.KeyboardAiCompanionCommentService;
import com.yolo.keyborad.service.KeyboardAiCompanionLikeService;
import com.yolo.keyborad.service.KeyboardAiChatMessageService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
import com.yolo.keyborad.mapper.KeyboardAiCompanionMapper;
import com.yolo.keyborad.service.KeyboardAiCompanionService;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/*
* @author: ziin
* @date: 2026/1/26 13:51
@@ -20,6 +31,15 @@ import com.yolo.keyborad.service.KeyboardAiCompanionService;
@Service
public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompanionMapper, KeyboardAiCompanion> implements KeyboardAiCompanionService {
@Resource
private KeyboardAiCompanionLikeService companionLikeService;
@Resource
private KeyboardAiCompanionCommentService companionCommentService;
@Resource
private KeyboardAiChatMessageService chatMessageService;
@Override
public IPage<AiCompanionVO> pageList(Integer pageNum, Integer pageSize) {
Page<KeyboardAiCompanion> page = new Page<>(pageNum, pageSize);
@@ -29,7 +49,95 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
.orderByDesc(KeyboardAiCompanion::getSortOrder)
.orderByDesc(KeyboardAiCompanion::getPopularityScore);
IPage<KeyboardAiCompanion> entityPage = this.page(page, queryWrapper);
return entityPage.convert(entity -> BeanUtil.copyProperties(entity, AiCompanionVO.class));
// 获取所有角色ID
List<Long> companionIds = entityPage.getRecords().stream()
.map(KeyboardAiCompanion::getId)
.collect(Collectors.toList());
// 批量统计点赞数
Map<Long, Long> likeCountMap = Map.of();
if (!companionIds.isEmpty()) {
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
likeCountMap = likes.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
}
// 批量统计评论数
Map<Long, Long> commentCountMap = Map.of();
if (!companionIds.isEmpty()) {
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
commentCountMap = comments.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
}
// 转换为VO并填充统计数据
Map<Long, Long> finalLikeCountMap = likeCountMap;
Map<Long, Long> finalCommentCountMap = commentCountMap;
return entityPage.convert(entity -> {
AiCompanionVO vo = BeanUtil.copyProperties(entity, AiCompanionVO.class);
vo.setLikeCount(finalLikeCountMap.getOrDefault(entity.getId(), 0L).intValue());
vo.setCommentCount(finalCommentCountMap.getOrDefault(entity.getId(), 0L).intValue());
return vo;
});
}
@Override
public IPage<AiCompanionVO> pageListWithLikeStatus(Long userId, Integer pageNum, Integer pageSize) {
Page<KeyboardAiCompanion> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanion::getStatus, 1)
.eq(KeyboardAiCompanion::getVisibility, 1)
.orderByDesc(KeyboardAiCompanion::getSortOrder)
.orderByDesc(KeyboardAiCompanion::getPopularityScore);
IPage<KeyboardAiCompanion> entityPage = this.page(page, queryWrapper);
// 获取所有角色ID
List<Long> companionIds = entityPage.getRecords().stream()
.map(KeyboardAiCompanion::getId)
.collect(Collectors.toList());
// 批量统计点赞数
Map<Long, Long> likeCountMap = Map.of();
if (!companionIds.isEmpty()) {
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
likeCountMap = likes.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
}
// 批量统计评论数
Map<Long, Long> commentCountMap = Map.of();
if (!companionIds.isEmpty()) {
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
commentCountMap = comments.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
}
// 获取当前用户已点赞的角色ID
Set<Long> likedCompanionIds = companionLikeService.getLikedCompanionIds(userId, companionIds);
// 转换为VO并填充统计数据和点赞状态
Map<Long, Long> finalLikeCountMap = likeCountMap;
Map<Long, Long> finalCommentCountMap = commentCountMap;
return entityPage.convert(entity -> {
AiCompanionVO vo = BeanUtil.copyProperties(entity, AiCompanionVO.class);
vo.setLikeCount(finalLikeCountMap.getOrDefault(entity.getId(), 0L).intValue());
vo.setCommentCount(finalCommentCountMap.getOrDefault(entity.getId(), 0L).intValue());
vo.setLiked(likedCompanionIds.contains(entity.getId()));
return vo;
});
}
@Override
@@ -43,4 +151,150 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
}
return companion.getSystemPrompt();
}
@Override
public List<AiCompanionVO> getLikedCompanions(Long userId) {
// 获取用户点赞过的所有AI角色ID
List<Long> likedCompanionIds = companionLikeService.getAllLikedCompanionIds(userId);
if (likedCompanionIds.isEmpty()) {
return List.of();
}
// 查询这些AI角色的详细信息只返回已上线且可见的
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.in(KeyboardAiCompanion::getId, likedCompanionIds)
.eq(KeyboardAiCompanion::getStatus, 1)
.eq(KeyboardAiCompanion::getVisibility, 1)
.orderByDesc(KeyboardAiCompanion::getSortOrder)
.orderByDesc(KeyboardAiCompanion::getPopularityScore);
List<KeyboardAiCompanion> companions = this.list(queryWrapper);
if (companions.isEmpty()) {
return List.of();
}
// 获取实际查询到的角色ID
List<Long> companionIds = companions.stream()
.map(KeyboardAiCompanion::getId)
.collect(Collectors.toList());
// 批量统计点赞数
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
Map<Long, Long> likeCountMap = likes.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
// 批量统计评论数
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
Map<Long, Long> commentCountMap = comments.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
// 转换为VO并填充统计数据
return companions.stream().map(entity -> {
AiCompanionVO vo = BeanUtil.copyProperties(entity, AiCompanionVO.class);
vo.setLikeCount(likeCountMap.getOrDefault(entity.getId(), 0L).intValue());
vo.setCommentCount(commentCountMap.getOrDefault(entity.getId(), 0L).intValue());
vo.setLiked(true); // 用户已点赞
return vo;
}).collect(Collectors.toList());
}
@Override
public List<AiCompanionVO> getChattedCompanions(Long userId) {
// 获取用户聊过天的所有AI角色ID
List<Long> chattedCompanionIds = chatMessageService.getChattedCompanionIds(userId);
if (chattedCompanionIds.isEmpty()) {
return List.of();
}
// 查询这些AI角色的详细信息只返回已上线且可见的
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.in(KeyboardAiCompanion::getId, chattedCompanionIds)
.eq(KeyboardAiCompanion::getStatus, 1)
.eq(KeyboardAiCompanion::getVisibility, 1);
List<KeyboardAiCompanion> companions = this.list(queryWrapper);
if (companions.isEmpty()) {
return List.of();
}
// 获取实际查询到的角色ID
List<Long> companionIds = companions.stream()
.map(KeyboardAiCompanion::getId)
.collect(Collectors.toList());
// 批量统计点赞数
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
Map<Long, Long> likeCountMap = likes.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
// 批量统计评论数
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
Map<Long, Long> commentCountMap = comments.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
// 获取当前用户已点赞的角色ID
Set<Long> likedCompanionIds = companionLikeService.getLikedCompanionIds(userId, companionIds);
// 转换为VO并填充统计数据保持原有顺序按最近聊天时间
Map<Long, KeyboardAiCompanion> companionMap = companions.stream()
.collect(Collectors.toMap(KeyboardAiCompanion::getId, c -> c));
return chattedCompanionIds.stream()
.filter(companionMap::containsKey)
.map(id -> {
KeyboardAiCompanion entity = companionMap.get(id);
AiCompanionVO vo = BeanUtil.copyProperties(entity, AiCompanionVO.class);
vo.setLikeCount(likeCountMap.getOrDefault(entity.getId(), 0L).intValue());
vo.setCommentCount(commentCountMap.getOrDefault(entity.getId(), 0L).intValue());
vo.setLiked(likedCompanionIds.contains(entity.getId()));
return vo;
}).collect(Collectors.toList());
}
@Override
public AiCompanionVO getCompanionById(Long userId, Long companionId) {
// 查询AI角色
KeyboardAiCompanion companion = this.getById(companionId);
if (companion == null) {
throw new BusinessException(ErrorCode.COMPANION_NOT_FOUND);
}
if (companion.getStatus() != 1 || companion.getVisibility() != 1) {
throw new BusinessException(ErrorCode.COMPANION_NOT_FOUND);
}
// 统计点赞数
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
likeWrapper.eq(KeyboardAiCompanionLike::getCompanionId, companionId)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
long likeCount = companionLikeService.count(likeWrapper);
// 统计评论数
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
commentWrapper.eq(KeyboardAiCompanionComment::getCompanionId, companionId)
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
long commentCount = companionCommentService.count(commentWrapper);
// 获取当前用户点赞状态
boolean liked = companionLikeService.hasLiked(userId, companionId);
// 转换为VO
AiCompanionVO vo = BeanUtil.copyProperties(companion, AiCompanionVO.class);
vo.setLikeCount((int) likeCount);
vo.setCommentCount((int) commentCount);
vo.setLiked(liked);
return vo;
}
}

View File

@@ -106,3 +106,10 @@ elevenlabs:
voice-id: JBFqnCBsd6RMkjVDRZzb
model-id: eleven_turbo_v2_5
output-format: mp3_44100_128
deepgram:
api-key: 9c792eb63a65d644cbc95785155754cd1e84f8cf
model: nova-2
language: en
smart-format: true
punctuate: true

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardAiChatSessionMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardAiChatSession">
<!--@mbg.generated-->
<!--@Table keyboard_ai_chat_session-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="companion_id" jdbcType="BIGINT" property="companionId" />
<result column="reset_version" jdbcType="INTEGER" property="resetVersion" />
<result column="is_active" jdbcType="BOOLEAN" property="isActive" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="ended_at" jdbcType="TIMESTAMP" property="endedAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, companion_id, reset_version, is_active, created_at, ended_at
</sql>
</mapper>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardAiCompanionLikeMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardAiCompanionLike">
<!--@mbg.generated-->
<!--@Table keyboard_ai_companion_like-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="companion_id" jdbcType="BIGINT" property="companionId" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="status" jdbcType="SMALLINT" property="status" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, companion_id, user_id, "status", created_at, updated_at
</sql>
</mapper>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardAiCompanionReportMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardAiCompanionReport">
<!--@mbg.generated-->
<!--@Table keyboard_ai_companion_report-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="companion_id" jdbcType="BIGINT" property="companionId" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="report_type" jdbcType="SMALLINT" property="reportType" />
<result column="report_desc" jdbcType="VARCHAR" property="reportDesc" />
<result column="chat_context" jdbcType="VARCHAR" property="chatContext" />
<result column="evidence_image_url" jdbcType="VARCHAR" property="evidenceImageUrl" />
<result column="status" jdbcType="SMALLINT" property="status" />
<result column="admin_remark" jdbcType="VARCHAR" property="adminRemark" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, companion_id, user_id, report_type, report_desc, chat_context, evidence_image_url,
"status", admin_remark, created_at, updated_at
</sql>
</mapper>