Compare commits
3 Commits
f18217ba93
...
e68f1bea56
| Author | SHA1 | Date | |
|---|---|---|---|
| e68f1bea56 | |||
| b6d124619e | |||
| 6cf0275980 |
@@ -2,6 +2,6 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"started_at": "2026-01-26T13:01:18.447Z",
|
"started_at": "2026-01-26T13:01:18.447Z",
|
||||||
"original_prompt": "刚刚回滚了代码,现在AI陪聊角色评论需要使用KeyboardAiCompanionCommentLikeService添加一个评论点赞接口,用来记录点赞和取消点赞。 ulw",
|
"original_prompt": "刚刚回滚了代码,现在AI陪聊角色评论需要使用KeyboardAiCompanionCommentLikeService添加一个评论点赞接口,用来记录点赞和取消点赞。 ulw",
|
||||||
"reinforcement_count": 4,
|
"reinforcement_count": 8,
|
||||||
"last_checked_at": "2026-01-26T13:55:34.306Z"
|
"last_checked_at": "2026-01-27T10:35:42.226Z"
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ public enum ErrorCode {
|
|||||||
CHAT_SAVE_DATA_EMPTY(40010, "保存数据不能为空"),
|
CHAT_SAVE_DATA_EMPTY(40010, "保存数据不能为空"),
|
||||||
COMPANION_MESSAGE_EMPTY(40011, "消息内容不能为空"),
|
COMPANION_MESSAGE_EMPTY(40011, "消息内容不能为空"),
|
||||||
COMPANION_ID_EMPTY(40012, "AI陪聊角色ID不能为空"),
|
COMPANION_ID_EMPTY(40012, "AI陪聊角色ID不能为空"),
|
||||||
|
COMPANION_NOT_FOUND(40019, "AI陪聊角色不存在"),
|
||||||
COMMENT_CONTENT_EMPTY(40013, "评论内容不能为空"),
|
COMMENT_CONTENT_EMPTY(40013, "评论内容不能为空"),
|
||||||
COMMENT_NOT_FOUND(40014, "评论不存在"),
|
COMMENT_NOT_FOUND(40014, "评论不存在"),
|
||||||
COMMENT_ID_EMPTY(40015, "评论ID不能为空"),
|
COMMENT_ID_EMPTY(40015, "评论ID不能为空"),
|
||||||
@@ -70,7 +71,11 @@ public enum ErrorCode {
|
|||||||
INVITE_CODE_ALREADY_BOUND(50028, "您已绑定过邀请码,无法重复绑定"),
|
INVITE_CODE_ALREADY_BOUND(50028, "您已绑定过邀请码,无法重复绑定"),
|
||||||
INVITE_CODE_CANNOT_BIND_SELF(50029, "不能绑定自己的邀请码"),
|
INVITE_CODE_CANNOT_BIND_SELF(50029, "不能绑定自己的邀请码"),
|
||||||
RECEIPT_ALREADY_PROCESSED(50027, "收据已处理"),
|
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, "语音转文字服务异常");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态码
|
* 状态码
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -114,7 +114,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/chat/audio/*",
|
"/chat/audio/*",
|
||||||
"/ai-companion/page",
|
"/ai-companion/page",
|
||||||
"/chat/history",
|
"/chat/history",
|
||||||
"/ai-companion/comment/add"
|
"/ai-companion/comment/add",
|
||||||
|
"/speech/transcribe"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package com.yolo.keyborad.controller;
|
package com.yolo.keyborad.controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
import com.yolo.keyborad.common.BaseResponse;
|
import com.yolo.keyborad.common.BaseResponse;
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
import com.yolo.keyborad.common.ResultUtils;
|
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.PageDTO;
|
||||||
|
import com.yolo.keyborad.model.dto.companion.CompanionLikeReq;
|
||||||
import com.yolo.keyborad.model.vo.AiCompanionVO;
|
import com.yolo.keyborad.model.vo.AiCompanionVO;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionLikeService;
|
||||||
import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
||||||
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;
|
||||||
@@ -25,10 +30,25 @@ public class AiCompanionController {
|
|||||||
@Resource
|
@Resource
|
||||||
private KeyboardAiCompanionService aiCompanionService;
|
private KeyboardAiCompanionService aiCompanionService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionLikeService aiCompanionLikeService;
|
||||||
|
|
||||||
@PostMapping("/page")
|
@PostMapping("/page")
|
||||||
@Operation(summary = "分页查询AI陪聊角色", description = "分页查询已上线的AI陪聊角色列表")
|
@Operation(summary = "分页查询AI陪聊角色", description = "分页查询已上线的AI陪聊角色列表")
|
||||||
public BaseResponse<IPage<AiCompanionVO>> pageList(@RequestBody PageDTO pageDTO) {
|
public BaseResponse<IPage<AiCompanionVO>> pageList(@RequestBody PageDTO pageDTO) {
|
||||||
IPage<AiCompanionVO> result = aiCompanionService.pageList(pageDTO.getPageNum(), pageDTO.getPageSize());
|
IPage<AiCompanionVO> result = aiCompanionService.pageList(pageDTO.getPageNum(), pageDTO.getPageSize());
|
||||||
return ResultUtils.success(result);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -55,6 +55,12 @@ public class AiCompanionVO {
|
|||||||
@Schema(description = "开场白音频")
|
@Schema(description = "开场白音频")
|
||||||
private String prologueAudio;
|
private String prologueAudio;
|
||||||
|
|
||||||
|
@Schema(description = "点赞总数")
|
||||||
|
private Integer likeCount;
|
||||||
|
|
||||||
|
@Schema(description = "评论总数")
|
||||||
|
private Integer commentCount;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
private Date createdAt;
|
private Date createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/main/java/com/yolo/keyborad/model/vo/SpeechToTextVO.java
Normal file
32
src/main/java/com/yolo/keyborad/model/vo/SpeechToTextVO.java
Normal 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;
|
||||||
|
}
|
||||||
29
src/main/java/com/yolo/keyborad/service/DeepgramService.java
Normal file
29
src/main/java/com/yolo/keyborad/service/DeepgramService.java
Normal 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,22 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
|||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.yolo.keyborad.common.ErrorCode;
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
import com.yolo.keyborad.exception.BusinessException;
|
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.model.vo.AiCompanionVO;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionCommentService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionLikeService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
|
||||||
import com.yolo.keyborad.mapper.KeyboardAiCompanionMapper;
|
import com.yolo.keyborad.mapper.KeyboardAiCompanionMapper;
|
||||||
import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
* @date: 2026/1/26 13:51
|
* @date: 2026/1/26 13:51
|
||||||
@@ -20,6 +29,12 @@ import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
|||||||
@Service
|
@Service
|
||||||
public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompanionMapper, KeyboardAiCompanion> implements KeyboardAiCompanionService {
|
public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompanionMapper, KeyboardAiCompanion> implements KeyboardAiCompanionService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionLikeService companionLikeService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionCommentService companionCommentService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public IPage<AiCompanionVO> pageList(Integer pageNum, Integer pageSize) {
|
public IPage<AiCompanionVO> pageList(Integer pageNum, Integer pageSize) {
|
||||||
Page<KeyboardAiCompanion> page = new Page<>(pageNum, pageSize);
|
Page<KeyboardAiCompanion> page = new Page<>(pageNum, pageSize);
|
||||||
@@ -29,7 +44,43 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
|
|||||||
.orderByDesc(KeyboardAiCompanion::getSortOrder)
|
.orderByDesc(KeyboardAiCompanion::getSortOrder)
|
||||||
.orderByDesc(KeyboardAiCompanion::getPopularityScore);
|
.orderByDesc(KeyboardAiCompanion::getPopularityScore);
|
||||||
IPage<KeyboardAiCompanion> entityPage = this.page(page, queryWrapper);
|
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
|
@Override
|
||||||
|
|||||||
@@ -106,3 +106,10 @@ elevenlabs:
|
|||||||
voice-id: JBFqnCBsd6RMkjVDRZzb
|
voice-id: JBFqnCBsd6RMkjVDRZzb
|
||||||
model-id: eleven_turbo_v2_5
|
model-id: eleven_turbo_v2_5
|
||||||
output-format: mp3_44100_128
|
output-format: mp3_44100_128
|
||||||
|
|
||||||
|
deepgram:
|
||||||
|
api-key: 9c792eb63a65d644cbc95785155754cd1e84f8cf
|
||||||
|
model: nova-2
|
||||||
|
language: en
|
||||||
|
smart-format: true
|
||||||
|
punctuate: true
|
||||||
18
src/main/resources/mapper/KeyboardAiCompanionLikeMapper.xml
Normal file
18
src/main/resources/mapper/KeyboardAiCompanionLikeMapper.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user