Compare commits

...

4 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
9 changed files with 335 additions and 6 deletions

View File

@@ -75,7 +75,10 @@ public enum ErrorCode {
AUDIO_FILE_EMPTY(40016, "音频文件不能为空"),
AUDIO_FILE_TOO_LARGE(40017, "音频文件过大"),
AUDIO_FORMAT_NOT_SUPPORTED(40018, "音频格式不支持"),
STT_SERVICE_ERROR(50031, "语音转文字服务异常");
STT_SERVICE_ERROR(50031, "语音转文字服务异常"),
REPORT_TYPE_INVALID(40020, "举报类型无效"),
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
REPORT_TYPE_EMPTY(40022, "举报类型不能为空");
/**
* 状态码

View File

@@ -8,8 +8,10 @@ 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;
@@ -35,6 +37,9 @@ public class AiCompanionController {
@Resource
private KeyboardAiCompanionLikeService aiCompanionLikeService;
@Resource
private KeyboardAiCompanionReportService aiCompanionReportService;
@PostMapping("/page")
@Operation(summary = "分页查询AI陪聊角色", description = "分页查询已上线的AI陪聊角色列表包含点赞数、评论数和当前用户点赞状态")
public BaseResponse<IPage<AiCompanionVO>> pageList(@RequestBody PageDTO pageDTO) {
@@ -81,4 +86,12 @@ public class AiCompanionController {
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

@@ -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,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

@@ -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

@@ -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

@@ -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);
@@ -47,14 +80,31 @@ public class KeyboardAiChatMessageServiceImpl extends ServiceImpl<KeyboardAiChat
@Override
public List<Long> getChattedCompanionIds(Long userId) {
// 使用原生SQL查询用户聊过天的所有角色ID按最近聊天时间倒序
// 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)
.select(KeyboardAiChatMessage::getCompanionId)
.groupBy(KeyboardAiChatMessage::getCompanionId)
.orderByDesc(KeyboardAiChatMessage::getCompanionId);
.in(KeyboardAiChatMessage::getSessionId, activeSessionIds)
.orderByDesc(KeyboardAiChatMessage::getCreatedAt);
List<KeyboardAiChatMessage> messages = this.list(queryWrapper);
// 5. 去重并保持顺序(按最近聊天时间)
return messages.stream()
.map(KeyboardAiChatMessage::getCompanionId)
.distinct()

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

@@ -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>