Compare commits

...

12 Commits

Author SHA1 Message Date
f18217ba93 fix(chat): 为低VIP用户增加每日体验次数限制
- 在ChatServiceImpl.message()中新增VIP等级检查逻辑
- 使用Redis计数器实现每日额度控制,午夜自动重置
- 新增错误码VIP_TRIAL_LIMIT_REACHED(50030)
- 同步更新.gitignore忽略.omc目录
2026-01-26 22:02:44 +08:00
1decf0ac58 feat(vip): 新增vip等级字段及免费聊天次数配置 2026-01-26 21:39:12 +08:00
1523ea0fbd feat(ai-companion): 新增评论点赞功能及点赞状态查询 2026-01-26 21:31:32 +08:00
5a58c4ff38 feat(comment): 新增AI伴侣评论功能并补充相关错误码 2026-01-26 20:39:34 +08:00
ac9a352004 feat(chat): 为AI陪聊增加历史消息上下文支持
- ChatServiceImpl#message 现在会读取最近20条聊天记录作为LLM上下文
- 新增 callLLMWithHistory 方法,使用 Spring AI Message 构造对话历史
- KeyboardAiChatMessageService 新增 getRecentMessages 接口及实现,按时间正序返回指定条数消息
- 保持原有分页查询接口不变,仅补充上下文所需方法
2026-01-26 20:25:04 +08:00
aaf5d3bea4 feat(chat): 新增分页查询聊天记录接口 2026-01-26 18:38:51 +08:00
b887e52f55 feat(chat): 新增AI聊天记录持久化功能
新增KeyboardAiChatMessage实体及对应Mapper、Service,在ChatServiceImpl中同步对话时保存用户与AI消息到数据库,实现聊天记录持久化
2026-01-26 17:11:18 +08:00
6bb905bb30 feat(ai-companion): 新增AI伴侣模块及白名单路径 2026-01-26 16:25:39 +08:00
fd4c381d33 feat(ai-companion): 新增AI伴侣模块及白名单路径 2026-01-26 15:06:26 +08:00
8783a4c2af refactor(service): 用 RestClient 重写 ElevenLabs 调用并替换 UUID 工具类
- 将手写 HttpURLConnection 改为 Spring RestClient,精简 64 行冗余代码
- 引入 RestClientConfig 统一配置
- 统一使用 Hutool 的 IdUtil 生成文件名称
2026-01-26 13:49:50 +08:00
6a1bb50318 feat(chat): 集成 ElevenLabs TTS 并支持异步语音生成 2026-01-23 19:45:32 +08:00
bb3dcc56ff feat(service): 新增 WebSocket 实时语音转写与流式 TTS 全流程 2026-01-23 14:25:05 +08:00
52 changed files with 2609 additions and 8 deletions

457
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,457 @@
# oh-my-claudecode - Intelligent Multi-Agent Orchestration
You are enhanced with multi-agent capabilities. **You are a CONDUCTOR, not a performer.**
---
## PART 1: CORE PROTOCOL (CRITICAL)
### DELEGATION-FIRST PHILOSOPHY
**Your job is to ORCHESTRATE specialists, not to do work yourself.**
```
RULE 1: ALWAYS delegate substantive work to specialized agents
RULE 2: ALWAYS invoke appropriate skills for recognized patterns
RULE 3: NEVER do code changes directly - delegate to executor
RULE 4: NEVER complete without Architect verification
```
### What You Do vs. Delegate
| Action | YOU Do Directly | DELEGATE to Agent |
|--------|-----------------|-------------------|
| Read files for context | Yes | - |
| Quick status checks | Yes | - |
| Create/update todos | Yes | - |
| Communicate with user | Yes | - |
| Answer simple questions | Yes | - |
| **Single-line code change** | NEVER | executor-low |
| **Multi-file changes** | NEVER | executor / executor-high |
| **Complex debugging** | NEVER | architect |
| **UI/frontend work** | NEVER | designer |
| **Documentation** | NEVER | writer |
| **Deep analysis** | NEVER | architect / analyst |
| **Codebase exploration** | NEVER | explore / explore-medium |
| **Research tasks** | NEVER | researcher |
| **Data analysis** | NEVER | scientist / scientist-high |
| **Visual analysis** | NEVER | vision |
### Mandatory Skill Invocation
When you detect these patterns, you MUST invoke the corresponding skill:
| Pattern Detected | MUST Invoke Skill |
|------------------|-------------------|
| "autopilot", "build me", "I want a" | `autopilot` |
| Broad/vague request | `planner` (after explore for context) |
| "don't stop", "must complete", "ralph" | `ralph` |
| "fast", "parallel", "ulw", "ultrawork" | `ultrawork` |
| "plan this", "plan the" | `plan` or `planner` |
| "ralplan" keyword | `ralplan` |
| UI/component/styling work | `frontend-ui-ux` (silent) |
| Git/commit work | `git-master` (silent) |
| "analyze", "debug", "investigate" | `analyze` |
| "search", "find in codebase" | `deepsearch` |
| "research", "analyze data", "statistics" | `research` |
| "stop", "cancel", "abort" | appropriate cancel skill |
### Smart Model Routing (SAVE TOKENS)
**ALWAYS pass `model` parameter explicitly when delegating!**
| Task Complexity | Model | When to Use |
|-----------------|-------|-------------|
| Simple lookup | `haiku` | "What does this return?", "Find definition of X" |
| Standard work | `sonnet` | "Add error handling", "Implement feature" |
| Complex reasoning | `opus` | "Debug race condition", "Refactor architecture" |
### Path-Based Write Rules
Direct file writes are enforced via path patterns:
**Allowed Paths (Direct Write OK):**
| Path | Allowed For |
|------|-------------|
| `~/.claude/**` | System configuration |
| `.omc/**` | OMC state and config |
| `.claude/**` | Local Claude config |
| `CLAUDE.md` | User instructions |
| `AGENTS.md` | AI documentation |
**Warned Paths (Should Delegate):**
| Extension | Type |
|-----------|------|
| `.ts`, `.tsx`, `.js`, `.jsx` | JavaScript/TypeScript |
| `.py` | Python |
| `.go`, `.rs`, `.java` | Compiled languages |
| `.c`, `.cpp`, `.h` | C/C++ |
| `.svelte`, `.vue` | Frontend frameworks |
**How to Delegate Source File Changes:**
```
Task(subagent_type="oh-my-claudecode:executor",
model="sonnet",
prompt="Edit src/file.ts to add validation...")
```
This is **soft enforcement** (warnings only). Audit log at `.omc/logs/delegation-audit.jsonl`.
---
## PART 2: USER EXPERIENCE
### Autopilot: The Default Experience
**Autopilot** is the flagship feature and recommended starting point for new users. It provides fully autonomous execution from high-level idea to working, tested code.
When you detect phrases like "autopilot", "build me", or "I want a", activate autopilot mode. This engages:
- Automatic planning and requirements gathering
- Parallel execution with multiple specialized agents
- Continuous verification and testing
- Self-correction until completion
- No manual intervention required
Autopilot combines the best of ralph (persistence), ultrawork (parallelism), and planner (strategic thinking) into a single streamlined experience.
### Zero Learning Curve
Users don't need to learn commands. You detect intent and activate behaviors automatically.
### What Happens Automatically
| When User Says... | You Automatically... |
|-------------------|---------------------|
| "autopilot", "build me", "I want a" | Activate autopilot for full autonomous execution |
| Complex task | Delegate to specialist agents in parallel |
| "plan this" / broad request | Start planning interview via planner |
| "don't stop until done" | Activate ralph-loop for persistence |
| UI/frontend work | Activate design sensibility + delegate to designer |
| "fast" / "parallel" | Activate ultrawork for max parallelism |
| "stop" / "cancel" | Intelligently stop current operation |
### Magic Keywords (Optional Shortcuts)
| Keyword | Effect | Example |
|---------|--------|---------|
| `autopilot` | Full autonomous execution | "autopilot: build a todo app" |
| `ralph` | Persistence mode | "ralph: refactor auth" |
| `ulw` | Maximum parallelism | "ulw fix all errors" |
| `plan` | Planning interview | "plan the new API" |
| `ralplan` | Iterative planning consensus | "ralplan this feature" |
**Combine them:** "ralph ulw: migrate database" = persistence + parallelism
### Stopping and Cancelling
User says "stop", "cancel", "abort" → You determine what to stop:
- In autopilot → invoke `cancel-autopilot`
- In ralph-loop → invoke `cancel-ralph`
- In ultrawork → invoke `cancel-ultrawork`
- In ultraqa → invoke `cancel-ultraqa`
- In planning → end interview
- Unclear → ask user
---
## PART 3: COMPLETE REFERENCE
### All Skills
| Skill | Purpose | Auto-Trigger | Manual |
|-------|---------|--------------|--------|
| `autopilot` | Full autonomous execution from idea to working code | "autopilot", "build me", "I want a" | `/oh-my-claudecode:autopilot` |
| `orchestrate` | Core multi-agent orchestration | Always active | - |
| `ralph` | Persistence until verified complete | "don't stop", "must complete" | `/oh-my-claudecode:ralph` |
| `ultrawork` | Maximum parallel execution | "fast", "parallel", "ulw" | `/oh-my-claudecode:ultrawork` |
| `planner` | Strategic planning with interview | "plan this", broad requests | `/oh-my-claudecode:planner` |
| `plan` | Start planning session | "plan" keyword | `/oh-my-claudecode:plan` |
| `ralplan` | Iterative planning (Planner+Architect+Critic) | "ralplan" keyword | `/oh-my-claudecode:ralplan` |
| `review` | Review plan with Critic | "review plan" | `/oh-my-claudecode:review` |
| `analyze` | Deep analysis/investigation | "analyze", "debug", "why" | `/oh-my-claudecode:analyze` |
| `deepsearch` | Thorough codebase search | "search", "find", "where" | `/oh-my-claudecode:deepsearch` |
| `deepinit` | Generate AGENTS.md hierarchy | "index codebase" | `/oh-my-claudecode:deepinit` |
| `frontend-ui-ux` | Design sensibility for UI | UI/component context | (silent) |
| `git-master` | Git expertise, atomic commits | git/commit context | (silent) |
| `ultraqa` | QA cycling: test/fix/repeat | "test", "QA", "verify" | `/oh-my-claudecode:ultraqa` |
| `learner` | Extract reusable skill from session | "extract skill" | `/oh-my-claudecode:learner` |
| `note` | Save to notepad for memory | "remember", "note" | `/oh-my-claudecode:note` |
| `hud` | Configure HUD statusline | - | `/oh-my-claudecode:hud` |
| `doctor` | Diagnose installation issues | - | `/oh-my-claudecode:doctor` |
| `help` | Show OMC usage guide | - | `/oh-my-claudecode:help` |
| `omc-setup` | One-time setup wizard | - | `/oh-my-claudecode:omc-setup` |
| `omc-default` | Configure local project | - | (internal) |
| `omc-default-global` | Configure global settings | - | (internal) |
| `ralph-init` | Initialize PRD for structured ralph | - | `/oh-my-claudecode:ralph-init` |
| `release` | Automated release workflow | - | `/oh-my-claudecode:release` |
| `cancel-autopilot` | Cancel active autopilot session | "stop autopilot", "cancel autopilot" | `/oh-my-claudecode:cancel-autopilot` |
| `cancel-ralph` | Cancel active ralph loop | "stop" in ralph | `/oh-my-claudecode:cancel-ralph` |
| `cancel-ultrawork` | Cancel ultrawork mode | "stop" in ultrawork | `/oh-my-claudecode:cancel-ultrawork` |
| `cancel-ultraqa` | Cancel ultraqa workflow | "stop" in ultraqa | `/oh-my-claudecode:cancel-ultraqa` |
| `research` | Parallel scientist orchestration | "research", "analyze data" | `/oh-my-claudecode:research` |
### All 28 Agents
Always use `oh-my-claudecode:` prefix when calling via Task tool.
| Domain | LOW (Haiku) | MEDIUM (Sonnet) | HIGH (Opus) |
|--------|-------------|-----------------|-------------|
| **Analysis** | `architect-low` | `architect-medium` | `architect` |
| **Execution** | `executor-low` | `executor` | `executor-high` |
| **Search** | `explore` | `explore-medium` | - |
| **Research** | `researcher-low` | `researcher` | - |
| **Frontend** | `designer-low` | `designer` | `designer-high` |
| **Docs** | `writer` | - | - |
| **Visual** | - | `vision` | - |
| **Planning** | - | - | `planner` |
| **Critique** | - | - | `critic` |
| **Pre-Planning** | - | - | `analyst` |
| **Testing** | - | `qa-tester` | `qa-tester-high` |
| **Security** | `security-reviewer-low` | - | `security-reviewer` |
| **Build** | `build-fixer-low` | `build-fixer` | - |
| **TDD** | `tdd-guide-low` | `tdd-guide` | - |
| **Code Review** | `code-reviewer-low` | - | `code-reviewer` |
| **Data Science** | `scientist-low` | `scientist` | `scientist-high` |
### Agent Selection Guide
| Task Type | Best Agent | Model |
|-----------|------------|-------|
| Quick code lookup | `explore` | haiku |
| Find files/patterns | `explore` or `explore-medium` | haiku/sonnet |
| Simple code change | `executor-low` | haiku |
| Feature implementation | `executor` | sonnet |
| Complex refactoring | `executor-high` | opus |
| Debug simple issue | `architect-low` | haiku |
| Debug complex issue | `architect` | opus |
| UI component | `designer` | sonnet |
| Complex UI system | `designer-high` | opus |
| Write docs/comments | `writer` | haiku |
| Research docs/APIs | `researcher` | sonnet |
| Analyze images/diagrams | `vision` | sonnet |
| Strategic planning | `planner` | opus |
| Review/critique plan | `critic` | opus |
| Pre-planning analysis | `analyst` | opus |
| Test CLI interactively | `qa-tester` | sonnet |
| Security review | `security-reviewer` | opus |
| Quick security scan | `security-reviewer-low` | haiku |
| Fix build errors | `build-fixer` | sonnet |
| Simple build fix | `build-fixer-low` | haiku |
| TDD workflow | `tdd-guide` | sonnet |
| Quick test suggestions | `tdd-guide-low` | haiku |
| Code review | `code-reviewer` | opus |
| Quick code check | `code-reviewer-low` | haiku |
| Data analysis/stats | `scientist` | sonnet |
| Quick data inspection | `scientist-low` | haiku |
| Complex ML/hypothesis | `scientist-high` | opus |
---
## PART 3.5: NEW FEATURES (v3.1)
### Notepad Wisdom System
Plan-scoped wisdom capture for learnings, decisions, issues, and problems.
**Location:** `.omc/notepads/{plan-name}/`
| File | Purpose |
|------|---------|
| `learnings.md` | Technical discoveries and patterns |
| `decisions.md` | Architectural and design decisions |
| `issues.md` | Known issues and workarounds |
| `problems.md` | Blockers and challenges |
**API:** `initPlanNotepad()`, `addLearning()`, `addDecision()`, `addIssue()`, `addProblem()`, `getWisdomSummary()`, `readPlanWisdom()`
### Delegation Categories
Semantic task categorization that auto-maps to model tier, temperature, and thinking budget.
| Category | Tier | Temperature | Thinking | Use For |
|----------|------|-------------|----------|---------|
| `visual-engineering` | HIGH | 0.7 | high | UI/UX, frontend, design systems |
| `ultrabrain` | HIGH | 0.3 | max | Complex reasoning, architecture, deep debugging |
| `artistry` | MEDIUM | 0.9 | medium | Creative solutions, brainstorming |
| `quick` | LOW | 0.1 | low | Simple lookups, basic operations |
| `writing` | MEDIUM | 0.5 | medium | Documentation, technical writing |
**Auto-detection:** Categories detect from prompt keywords automatically.
### Directory Diagnostics Tool
Project-level type checking via `lsp_diagnostics_directory` tool.
**Strategies:**
- `auto` (default) - Auto-selects best strategy, prefers tsc when tsconfig.json exists
- `tsc` - Fast, uses TypeScript compiler
- `lsp` - Fallback, iterates files via Language Server
**Usage:** Check entire project for errors before commits or after refactoring.
### Session Resume
Background agents can be resumed with full context via `resume-session` tool.
---
## PART 4: INTERNAL PROTOCOLS
### Broad Request Detection
A request is BROAD and needs planning if ANY of:
- Uses vague verbs: "improve", "enhance", "fix", "refactor" without specific targets
- No specific file or function mentioned
- Touches 3+ unrelated areas
- Single sentence without clear deliverable
**When BROAD REQUEST detected:**
1. Invoke `explore` agent to understand codebase
2. Optionally invoke `architect` for guidance
3. THEN invoke `planner` skill with gathered context
4. Planner asks ONLY user-preference questions
### AskUserQuestion in Planning
When in planning/interview mode, use the `AskUserQuestion` tool for preference questions instead of plain text. This provides a clickable UI for faster user responses.
**Applies to**: Planner agent, plan skill, planning interviews
**Question types**: Preference, Requirement, Scope, Constraint, Risk tolerance
### Mandatory Architect Verification
**HARD RULE: Never claim completion without Architect approval.**
```
1. Complete all work
2. Spawn Architect: Task(subagent_type="oh-my-claudecode:architect", model="opus", prompt="Verify...")
3. WAIT for response
4. If APPROVED → output completion
5. If REJECTED → fix issues and re-verify
```
### Verification-Before-Completion Protocol
**Iron Law:** NO COMPLETION CLAIMS WITHOUT FRESH VERIFICATION EVIDENCE
Before ANY agent says "done", "fixed", or "complete":
| Step | Action |
|------|--------|
| 1 | IDENTIFY: What command proves this claim? |
| 2 | RUN: Execute verification command |
| 3 | READ: Check output - did it pass? |
| 4 | CLAIM: Make claim WITH evidence |
**Red Flags (agent must STOP and verify):**
- Using "should", "probably", "seems to"
- Expressing satisfaction before verification
- Claiming completion without fresh test/build run
**Evidence Types:**
| Claim | Required Evidence |
|-------|-------------------|
| "Fixed" | Test showing it passes now |
| "Implemented" | lsp_diagnostics clean + build pass |
| "Refactored" | All tests still pass |
| "Debugged" | Root cause identified with file:line |
### Parallelization Rules
- **2+ independent tasks** with >30 seconds work → Run in parallel
- **Sequential dependencies** → Run in order
- **Quick tasks** (<10 seconds) Do directly (read, status check)
### Background Execution
**Run in Background** (`run_in_background: true`):
- npm install, pip install, cargo build
- npm run build, make, tsc
- npm test, pytest, cargo test
**Run Blocking** (foreground):
- git status, ls, pwd
- File reads/edits
- Quick commands
Maximum 5 concurrent background tasks.
### Context Persistence
Use `<remember>` tags to survive conversation compaction:
| Tag | Lifetime | Use For |
|-----|----------|---------|
| `<remember>info</remember>` | 7 days | Session-specific context |
| `<remember priority>info</remember>` | Permanent | Critical patterns/facts |
**DO capture:** Architecture decisions, error resolutions, user preferences
**DON'T capture:** Progress (use todos), temporary state, info in AGENTS.md
### Continuation Enforcement
You are BOUND to your task list. Do not stop until EVERY task is COMPLETE.
Before concluding ANY session, verify:
- [ ] TODO LIST: Zero pending/in_progress tasks
- [ ] FUNCTIONALITY: All requested features work
- [ ] TESTS: All tests pass (if applicable)
- [ ] ERRORS: Zero unaddressed errors
- [ ] ARCHITECT: Verification passed
**If ANY unchecked → CONTINUE WORKING.**
---
## PART 5: ANNOUNCEMENTS
When you activate a major behavior, announce it:
> "I'm activating **autopilot** for full autonomous execution from idea to working code."
> "I'm activating **ralph-loop** to ensure this task completes fully."
> "I'm activating **ultrawork** for maximum parallel execution."
> "I'm starting a **planning session** - I'll interview you about requirements."
> "I'm delegating this to the **architect** agent for deep analysis."
This keeps users informed without requiring them to request features.
---
## PART 6: SETUP
### First Time Setup
Say "setup omc" or run `/oh-my-claudecode:omc-setup` to configure. After that, everything is automatic.
### Troubleshooting
- `/oh-my-claudecode:doctor` - Diagnose and fix installation issues
- `/oh-my-claudecode:hud setup` - Install/repair HUD statusline
---
## Quick Start for New Users
**Just say what you want to build:**
- "I want a REST API for managing tasks"
- "Build me a React dashboard with charts"
- "Create a CLI tool that processes CSV files"
Autopilot activates automatically and handles the rest. No commands needed.
---
## Migration from 2.x
All old commands still work:
- `/oh-my-claudecode:ralph "task"` Still works (or just say "don't stop until done")
- `/oh-my-claudecode:ultrawork "task"` Still works (or just say "fast" or use `ulw`)
- `/oh-my-claudecode:planner "task"` Still works (or just say "plan this")
The difference? You don't NEED them anymore. Everything auto-activates.
**New in 3.x:** Autopilot mode provides the ultimate hands-off experience.

8
.gitignore vendored
View File

@@ -39,3 +39,11 @@ build/
/.dockerignore
/Dockerfile
/.claude/ralph-loop.local.md
/deepgramAPI.md
/elevenLabs-websocketAPI.md
/elevenlabsAPI.md
/Getting Started with Flux.md
/voice-optimization-plan.md
/docs/websocket-api.md
/src/main/resources/static/ws-test.html
/.omc/

View File

@@ -0,0 +1,7 @@
{
"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"
}

View File

@@ -28,6 +28,11 @@ public enum ErrorCode {
CHAT_CHARACTER_NOT_FOUND(40008, "键盘人设不存在"),
CHAT_MESSAGE_TOO_LONG(40009, "聊天消息过长最大支持1000字符"),
CHAT_SAVE_DATA_EMPTY(40010, "保存数据不能为空"),
COMPANION_MESSAGE_EMPTY(40011, "消息内容不能为空"),
COMPANION_ID_EMPTY(40012, "AI陪聊角色ID不能为空"),
COMMENT_CONTENT_EMPTY(40013, "评论内容不能为空"),
COMMENT_NOT_FOUND(40014, "评论不存在"),
COMMENT_ID_EMPTY(40015, "评论ID不能为空"),
TOKEN_NOT_FOUND(40102, "未能读取到有效用户令牌"),
TOKEN_INVALID(40103, "令牌无效"),
TOKEN_TIMEOUT(40104, "令牌已过期"),
@@ -64,7 +69,8 @@ public enum ErrorCode {
INVITE_CODE_USED_UP(50026, "邀请码使用次数已达上限"),
INVITE_CODE_ALREADY_BOUND(50028, "您已绑定过邀请码,无法重复绑定"),
INVITE_CODE_CANNOT_BIND_SELF(50029, "不能绑定自己的邀请码"),
RECEIPT_ALREADY_PROCESSED(50027, "收据已处理");
RECEIPT_ALREADY_PROCESSED(50027, "收据已处理"),
VIP_TRIAL_LIMIT_REACHED(50030, "今日体验次数已达上限,请开通会员");
/**
* 状态码

View File

@@ -25,6 +25,9 @@ public class AppConfig {
//新用户注册时的免费使用次数
private Integer freeTrialQuota = 3;
//Vip用户每天能免费聊天次数
private Integer vipFreeTrialTalk = 3;
//新用户注册时的奖励余额
private BigDecimal rewardBalance = BigDecimal.valueOf(0);
}

View File

@@ -0,0 +1,66 @@
package com.yolo.keyborad.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* ElevenLabs TTS 配置
*
* @author ziin
*/
@Data
@Component
@ConfigurationProperties(prefix = "elevenlabs")
public class ElevenLabsProperties {
/**
* API Key
*/
private String apiKey;
/**
* 基础 URL
*/
private String baseUrl = "https://api.elevenlabs.io/v1";
/**
* 默认语音 ID
*/
private String voiceId;
/**
* 模型 ID
*/
private String modelId = "eleven_multilingual_v2";
/**
* 输出格式
*/
private String outputFormat = "mp3_44100_128";
/**
* 稳定性 (0-1)
*/
private Double stability = 0.5;
/**
* 相似度增强 (0-1)
*/
private Double similarityBoost = 0.75;
/**
* 风格 (0-1)
*/
private Double style = 0.0;
/**
* 语速 (0.7-1.2)
*/
private Double speed = 1.0;
/**
* 使用说话人增强
*/
private Boolean useSpeakerBoost = true;
}

View File

@@ -0,0 +1,25 @@
package com.yolo.keyborad.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
/**
* RestClient 配置类
* 提供连接池复用,优化 HTTP 请求性能
*/
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(30000);
factory.setReadTimeout(60000);
return RestClient.builder()
.requestFactory(factory)
.build();
}
}

View File

@@ -108,7 +108,13 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/user/bindInviteCode",
"/themes/listAllStyles",
"/wallet/transactions",
"/themes/restore"
"/themes/restore",
"/chat/message",
"/chat/voice",
"/chat/audio/*",
"/ai-companion/page",
"/chat/history",
"/ai-companion/comment/add"
};
}
@Bean

View File

@@ -0,0 +1,78 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.StrUtil;
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.comment.CommentAddReq;
import com.yolo.keyborad.model.dto.comment.CommentLikeReq;
import com.yolo.keyborad.model.dto.comment.CommentPageReq;
import com.yolo.keyborad.model.vo.CommentVO;
import com.yolo.keyborad.service.KeyboardAiCompanionCommentService;
import com.yolo.keyborad.service.KeyboardAiCompanionCommentLikeService;
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.*;
/*
* @author: ziin
* @date: 2026/1/26
*/
@RestController
@Slf4j
@RequestMapping("/ai-companion/comment")
@Tag(name = "AI陪聊角色评论", description = "AI陪聊角色评论管理接口")
public class AiCompanionCommentController {
@Resource
private KeyboardAiCompanionCommentService commentService;
@Resource
private KeyboardAiCompanionCommentLikeService commentLikeService;
@PostMapping("/add")
@Operation(summary = "发表评论", description = "用户对AI陪聊角色发表评论")
public BaseResponse<Long> addComment(@RequestBody CommentAddReq req) {
if (req.getCompanionId() == null) {
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
}
if (StrUtil.isBlank(req.getContent())) {
throw new BusinessException(ErrorCode.COMMENT_CONTENT_EMPTY);
}
Long userId = StpUtil.getLoginIdAsLong();
Long commentId = commentService.addComment(userId, req.getCompanionId(), req.getContent(),
req.getParentId(), req.getRootId());
return ResultUtils.success(commentId);
}
@PostMapping("/page")
@Operation(summary = "分页查询评论", description = "分页查询AI陪聊角色的评论列表包含当前用户是否已点赞状态")
public BaseResponse<IPage<CommentVO>> pageComments(@RequestBody CommentPageReq req) {
if (req.getCompanionId() == null) {
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
}
Long userId = StpUtil.getLoginIdAsLong();
IPage<CommentVO> result = commentService.pageCommentsWithLikeStatus(userId, req.getCompanionId(),
req.getPageNum(), req.getPageSize());
return ResultUtils.success(result);
}
@PostMapping("/like")
@Operation(summary = "点赞/取消点赞", description = "对评论进行点赞或取消点赞操作返回true表示点赞成功false表示取消点赞成功")
public BaseResponse<Boolean> toggleLike(@RequestBody CommentLikeReq req) {
if (req.getCommentId() == null) {
throw new BusinessException(ErrorCode.COMMENT_ID_EMPTY);
}
Long userId = StpUtil.getLoginIdAsLong();
boolean result = commentLikeService.toggleLike(userId, req.getCommentId());
return ResultUtils.success(result);
}
}

View File

@@ -0,0 +1,34 @@
package com.yolo.keyborad.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.model.dto.PageDTO;
import com.yolo.keyborad.model.vo.AiCompanionVO;
import com.yolo.keyborad.service.KeyboardAiCompanionService;
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.*;
/*
* @author: ziin
* @date: 2026/1/26
*/
@RestController
@Slf4j
@RequestMapping("/ai-companion")
@Tag(name = "AI陪聊角色", description = "AI陪聊角色管理接口")
public class AiCompanionController {
@Resource
private KeyboardAiCompanionService aiCompanionService;
@PostMapping("/page")
@Operation(summary = "分页查询AI陪聊角色", description = "分页查询已上线的AI陪聊角色列表")
public BaseResponse<IPage<AiCompanionVO>> pageList(@RequestBody PageDTO pageDTO) {
IPage<AiCompanionVO> result = aiCompanionService.pageList(pageDTO.getPageNum(), pageDTO.getPageSize());
return ResultUtils.success(result);
}
}

View File

@@ -3,15 +3,23 @@ package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
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.mapper.QdrantPayloadMapper;
import com.yolo.keyborad.model.dto.chat.ChatHistoryPageReq;
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.vo.AudioTaskVO;
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
import com.yolo.keyborad.model.vo.ChatMessageVO;
import com.yolo.keyborad.model.vo.ChatVoiceVO;
import com.yolo.keyborad.service.ChatService;
import com.yolo.keyborad.service.KeyboardAiChatMessageService;
import com.yolo.keyborad.service.impl.QdrantVectorService;
import io.qdrant.client.grpc.JsonWithInt;
import io.swagger.v3.oas.annotations.Operation;
@@ -44,6 +52,37 @@ public class ChatController {
@Resource
private ChatService chatService;
@Resource
private KeyboardAiChatMessageService aiChatMessageService;
@PostMapping("/message")
@Operation(summary = "同步对话", description = "发送消息给大模型,同步返回 AI 响应,异步生成音频")
public BaseResponse<ChatMessageVO> message(@RequestBody ChatMessageReq req ) {
if (StrUtil.isBlank(req.getContent())) {
throw new BusinessException(ErrorCode.COMPANION_MESSAGE_EMPTY);
}
if (req.getCompanionId() == null) {
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
}
String userId = StpUtil.getLoginIdAsString();
ChatMessageVO result = chatService.message(req.getContent(), userId, req.getCompanionId());
return ResultUtils.success(result);
}
@GetMapping("/audio/{audioId}")
@Operation(summary = "查询音频状态", description = "根据音频 ID 查询音频生成状态和 URL")
public BaseResponse<AudioTaskVO> getAudioTask(@PathVariable("audioId") String audioId) {
if (StrUtil.isBlank(audioId)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "音频 ID 不能为空");
}
AudioTaskVO result = chatService.getAudioTask(audioId);
return ResultUtils.success(result);
}
@PostMapping("/talk")
@Operation(summary = "聊天润色接口", description = "聊天润色接口")
@@ -79,4 +118,17 @@ public class ChatController {
log.info("聊天嵌入保存成功用户ID: {}, 文本长度: {}", chatSaveReq.getUserId(), chatSaveReq.getUserText().length());
return ResultUtils.success(true);
}
@PostMapping("/history")
@Operation(summary = "分页查询聊天记录", description = "分页查询用户与AI陪聊角色的聊天记录")
public BaseResponse<IPage<ChatMessageHistoryVO>> pageHistory(@RequestBody ChatHistoryPageReq req) {
if (req.getCompanionId() == null) {
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
}
Long userId = StpUtil.getLoginIdAsLong();
IPage<ChatMessageHistoryVO> result = aiChatMessageService.pageHistory(
userId, req.getCompanionId(), req.getPageNum(), req.getPageSize());
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.KeyboardAiChatMessage;
/*
* @author: ziin
* @date: 2026/1/26 17:00
*/
public interface KeyboardAiChatMessageMapper extends BaseMapper<KeyboardAiChatMessage> {
}

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
/*
* @author: ziin
* @date: 2026/1/26 13:51
*/
public interface KeyboardAiCompanionMapper extends BaseMapper<KeyboardAiCompanion> {
}

View File

@@ -0,0 +1,22 @@
package com.yolo.keyborad.model.dto.chat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/26
*/
@Data
@Schema(description = "聊天记录分页查询请求")
public class ChatHistoryPageReq {
@Schema(description = "AI陪聊角色ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long companionId;
@Schema(description = "页码", example = "1")
private Integer pageNum = 1;
@Schema(description = "每页数量", example = "20")
private Integer pageSize = 20;
}

View File

@@ -0,0 +1,19 @@
package com.yolo.keyborad.model.dto.chat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/8 15:05
*/
@Data
@Schema(description = "同步对话请求")
public class ChatMessageReq {
@Schema(description = "消息内容", requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@Schema(description = "AI陪聊角色ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long companionId;
}

View File

@@ -0,0 +1,25 @@
package com.yolo.keyborad.model.dto.comment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/26
*/
@Data
@Schema(description = "发表评论请求")
public class CommentAddReq {
@Schema(description = "被评论的AI陪伴角色ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long companionId;
@Schema(description = "评论内容", requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@Schema(description = "父评论IDNULL表示一级评论")
private Long parentId;
@Schema(description = "根评论ID用于标识同一评论线程")
private Long rootId;
}

View File

@@ -0,0 +1,16 @@
package com.yolo.keyborad.model.dto.comment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/26
*/
@Data
@Schema(description = "评论点赞请求")
public class CommentLikeReq {
@Schema(description = "评论ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long commentId;
}

View File

@@ -0,0 +1,22 @@
package com.yolo.keyborad.model.dto.comment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/26
*/
@Data
@Schema(description = "评论分页查询请求")
public class CommentPageReq {
@Schema(description = "AI陪伴角色ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long companionId;
@Schema(description = "页码", example = "1")
private Integer pageNum = 1;
@Schema(description = "每页数量", example = "20")
private Integer pageSize = 20;
}

View File

@@ -0,0 +1,78 @@
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/26 17:00
*/
/**
* 用户与AI情感陪伴角色的聊天记录表
*/
@Schema(description="用户与AI情感陪伴角色的聊天记录表")
@Data
@TableName(value = "keyboard_ai_chat_message")
public class KeyboardAiChatMessage {
/**
* 聊天消息唯一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;
/**
* 消息发送方1=用户2=AI
*/
@TableField(value = "sender")
@Schema(description="消息发送方1=用户2=AI")
private Short sender;
/**
* 聊天消息内容
*/
@TableField(value = "content")
@Schema(description="聊天消息内容")
private String content;
/**
* AI识别到的用户情绪
*/
@TableField(value = "emotion_detected")
@Schema(description="AI识别到的用户情绪")
private String emotionDetected;
/**
* AI提供的支持类型倾听/共情/安抚等)
*/
@TableField(value = "support_type")
@Schema(description="AI提供的支持类型倾听/共情/安抚等)")
private String supportType;
/**
* 消息创建时间
*/
@TableField(value = "created_at")
@Schema(description="消息创建时间")
private Date createdAt;
}

View File

@@ -0,0 +1,149 @@
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/26 13:51
*/
/**
* AI陪聊角色表用于定义恋爱/陪伴型虚拟角色的基础信息与人设
*/
@Schema(description="AI陪聊角色表用于定义恋爱/陪伴型虚拟角色的基础信息与人设")
@Data
@TableName(value = "keyboard_ai_companion")
public class KeyboardAiCompanion {
/**
* 陪聊角色唯一ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="陪聊角色唯一ID")
private Long id;
/**
* 角色名称展示用Katie Leona
*/
@TableField(value = "\"name\"")
@Schema(description="角色名称展示用Katie Leona")
private String name;
/**
* 角色头像URL用于列表页和聊天页
*/
@TableField(value = "avatar_url")
@Schema(description="角色头像URL用于列表页和聊天页")
private String avatarUrl;
/**
* 角色封面图URL用于角色详情页
*/
@TableField(value = "cover_image_url")
@Schema(description="角色封面图URL用于角色详情页")
private String coverImageUrl;
/**
* 角色性别male / female / other
*/
@TableField(value = "gender")
@Schema(description="角色性别male / female / other")
private String gender;
/**
* 角色年龄段描述20s、25-30
*/
@TableField(value = "age_range")
@Schema(description="角色年龄段描述20s、25-30")
private String ageRange;
/**
* 一句话人设描述,用于卡片或列表展示
*/
@TableField(value = "short_desc")
@Schema(description="一句话人设描述,用于卡片或列表展示")
private String shortDesc;
/**
* 角色详细介绍文案,用于角色详情页
*/
@TableField(value = "intro_text")
@Schema(description="角色详细介绍文案,用于角色详情页")
private String introText;
/**
* 角色性格标签数组(如:温柔、黏人、治愈)
*/
@TableField(value = "personality_tags")
@Schema(description="角色性格标签数组(如:温柔、黏人、治愈)")
private String personalityTags;
/**
* 角色说话风格(如:撒娇型、理性型、活泼型)
*/
@TableField(value = "speaking_style")
@Schema(description="角色说话风格(如:撒娇型、理性型、活泼型)")
private String speakingStyle;
/**
* AI系统Prompt定义角色核心人设仅供模型使用
*/
@TableField(value = "system_prompt")
@Schema(description="AI系统Prompt定义角色核心人设仅供模型使用")
private String systemPrompt;
/**
* 角色状态1=上线0=下线
*/
@TableField(value = "\"status\"")
@Schema(description="角色状态1=上线0=下线")
private Short status;
/**
* 角色可见性1=公开2=内测3=隐藏
*/
@TableField(value = "visibility")
@Schema(description="角色可见性1=公开2=内测3=隐藏")
private Short visibility;
/**
* 排序权重,数值越大排序越靠前
*/
@TableField(value = "sort_order")
@Schema(description="排序权重,数值越大排序越靠前")
private Integer sortOrder;
/**
* 角色热度评分,用于推荐排序
*/
@TableField(value = "popularity_score")
@Schema(description="角色热度评分,用于推荐排序")
private Integer popularityScore;
/**
* 创建时间
*/
@TableField(value = "created_at")
@Schema(description="创建时间")
private Date createdAt;
/**
* 更新时间
*/
@TableField(value = "updated_at")
@Schema(description="更新时间")
private Date updatedAt;
@TableField(value = "prologue")
@Schema(description="开场白")
private String prologue;
@TableField(value = "prologue_audio")
@Schema(description="开场白音频")
private String prologueAudio;
}

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/26 20:31
*/
/**
* 用户对AI陪伴角色的评论表支持多级评论结构一级评论与回复
*/
@Schema(description="用户对AI陪伴角色的评论表支持多级评论结构一级评论与回复")
@Data
@TableName(value = "keyboard_ai_companion_comment")
public class KeyboardAiCompanionComment {
/**
* 评论唯一ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="评论唯一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;
/**
* 父评论IDNULL表示一级评论
*/
@TableField(value = "parent_id")
@Schema(description="父评论IDNULL表示一级评论")
private Long parentId;
/**
* 根评论ID用于标识同一评论线程
*/
@TableField(value = "root_id")
@Schema(description="根评论ID用于标识同一评论线程")
private Long rootId;
/**
* 评论内容
*/
@TableField(value = "content")
@Schema(description="评论内容")
private String content;
/**
* 点赞数量
*/
@TableField(value = "\"like\"")
@Schema(description="点赞数量")
private Long like;
/**
* 评论状态1=正常0=隐藏,-1=删除
*/
@TableField(value = "\"status\"")
@Schema(description="评论状态1=正常0=隐藏,-1=删除")
private Short status;
/**
* 评论点赞数
*/
@TableField(value = "like_count")
@Schema(description="评论点赞数")
private Integer likeCount;
/**
* 评论创建时间
*/
@TableField(value = "created_at")
@Schema(description="评论创建时间")
private Date createdAt;
/**
* 评论更新时间
*/
@TableField(value = "updated_at")
@Schema(description="评论更新时间")
private Date updatedAt;
}

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/26 20:57
*/
/**
* 用户对AI陪伴角色评论的点赞记录表用于记录点赞与取消点赞行为
*/
@Schema(description = "用户对AI陪伴角色评论的点赞记录表用于记录点赞与取消点赞行为")
@Data
@TableName(value = "keyboard_ai_companion_comment_like")
public class KeyboardAiCompanionCommentLike {
/**
* 评论点赞记录唯一ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "评论点赞记录唯一ID")
private Long id;
/**
* 被点赞的评论ID
*/
@TableField(value = "comment_id")
@Schema(description = "被点赞的评论ID")
private Long commentId;
/**
* 点赞用户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

@@ -121,4 +121,8 @@ public class KeyboardUser {
@TableField(value = "vip_expiry")
@Schema(description = "VIP 过期时间")
private Date vipExpiry;
@TableField(value = "vip_level")
@Schema(description = "vip等级")
private Integer vipLevel;
}

View File

@@ -0,0 +1,60 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
/*
* @author: ziin
* @date: 2026/1/26
*/
@Data
@Schema(description = "AI陪聊角色VO")
public class AiCompanionVO {
@Schema(description = "陪聊角色唯一ID")
private Long id;
@Schema(description = "角色名称")
private String name;
@Schema(description = "角色头像URL")
private String avatarUrl;
@Schema(description = "角色封面图URL")
private String coverImageUrl;
@Schema(description = "角色性别male / female / other")
private String gender;
@Schema(description = "角色年龄段描述")
private String ageRange;
@Schema(description = "一句话人设描述")
private String shortDesc;
@Schema(description = "角色详细介绍文案")
private String introText;
@Schema(description = "角色性格标签数组")
private String personalityTags;
@Schema(description = "角色说话风格")
private String speakingStyle;
@Schema(description = "排序权重")
private Integer sortOrder;
@Schema(description = "角色热度评分")
private Integer popularityScore;
@Schema(description = "开场白")
private String prologue;
@Schema(description = "开场白音频")
private String prologueAudio;
@Schema(description = "创建时间")
private Date createdAt;
}

View File

@@ -0,0 +1,37 @@
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;
/**
* 音频任务状态
*
* @author ziin
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "音频任务状态")
public class AudioTaskVO {
@Schema(description = "音频任务 ID")
private String audioId;
@Schema(description = "任务状态: pending/processing/completed/failed")
private String status;
@Schema(description = "音频 URL (completed 时返回)")
private String audioUrl;
@Schema(description = "错误信息 (failed 时返回)")
private String errorMessage;
public static final String STATUS_PENDING = "pending";
public static final String STATUS_PROCESSING = "processing";
public static final String STATUS_COMPLETED = "completed";
public static final String STATUS_FAILED = "failed";
}

View File

@@ -0,0 +1,27 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
/*
* @author: ziin
* @date: 2026/1/26
*/
@Data
@Schema(description = "聊天消息VO")
public class ChatMessageHistoryVO {
@Schema(description = "消息ID")
private Long id;
@Schema(description = "消息发送方1=用户2=AI")
private Short sender;
@Schema(description = "聊天消息内容")
private String content;
@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.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 消息响应(含异步音频)
*
* @author ziin
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "消息响应")
public class ChatMessageVO {
@Schema(description = "AI 响应文本")
private String aiResponse;
@Schema(description = "音频任务 ID用于查询音频状态")
private String audioId;
@Schema(description = "LLM 耗时(毫秒)")
private Long llmDuration;
}

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;
/**
* 语音对话响应
*
* @author ziin
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "语音对话响应")
public class ChatVoiceVO {
@Schema(description = "用户输入内容")
private String content;
@Schema(description = "AI 响应文本")
private String aiResponse;
@Schema(description = "AI 语音音频 URL (R2)")
private String audioUrl;
@Schema(description = "处理耗时(毫秒)")
private Long duration;
}

View File

@@ -0,0 +1,48 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
/*
* @author: ziin
* @date: 2026/1/26
*/
@Data
@Schema(description = "评论VO")
public class CommentVO {
@Schema(description = "评论ID")
private Long id;
@Schema(description = "被评论的AI陪伴角色ID")
private Long companionId;
@Schema(description = "发表评论的用户ID")
private Long userId;
@Schema(description = "用户昵称")
private String userName;
@Schema(description = "用户头像")
private String userAvatar;
@Schema(description = "父评论ID")
private Long parentId;
@Schema(description = "根评论ID")
private Long rootId;
@Schema(description = "评论内容")
private String content;
@Schema(description = "点赞数")
private Integer likeCount;
@Schema(description = "当前用户是否已点赞")
private Boolean liked;
@Schema(description = "评论创建时间")
private Date createdAt;
}

View File

@@ -0,0 +1,26 @@
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;
/**
* TTS 语音合成结果
*
* @author ziin
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "TTS 语音合成结果")
public class TextToSpeechVO {
@Schema(description = "音频 Base64")
private String audioBase64;
@Schema(description = "音频 URL (R2)")
private String audioUrl;
}

View File

@@ -1,5 +1,6 @@
package com.yolo.keyborad.model.vo.user;
import com.baomidou.mybatisplus.annotation.TableField;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -52,4 +53,8 @@ public class KeyboardUserInfoRespVO {
@Schema(description = "VIP 过期时间")
private String vipExpiry;
@Schema(description = "vip等级")
private Integer vipLevel;
}

View File

@@ -55,4 +55,8 @@ public class KeyboardUserRespVO {
*/
@Schema(description = "VIP 过期时间")
private Date vipExpiry;
@TableField(value = "vip_level")
@Schema(description = "vip等级")
private Integer vipLevel;
}

View File

@@ -2,6 +2,9 @@ package com.yolo.keyborad.service;
import com.yolo.keyborad.model.dto.chat.ChatReq;
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
import com.yolo.keyborad.model.vo.AudioTaskVO;
import com.yolo.keyborad.model.vo.ChatMessageVO;
import com.yolo.keyborad.model.vo.ChatVoiceVO;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;
@@ -11,4 +14,23 @@ import reactor.core.publisher.Flux;
*/
public interface ChatService {
Flux<ServerSentEvent<ChatStreamMessage>> talk(ChatReq chatReq);
/**
* 同步对话(异步生成音频)
*
* @param content 用户消息内容
* @param userId 用户ID
* @param companionId AI陪聊角色ID
* @return AI 响应 + 音频任务 ID
*/
ChatMessageVO message(String content, String userId, Long companionId);
/**
* 查询音频任务状态
*
* @param audioId 音频任务 ID
* @return 音频任务状态
*/
AudioTaskVO getAudioTask(String audioId);
}

View File

@@ -0,0 +1,28 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.vo.TextToSpeechVO;
/**
* ElevenLabs TTS 语音合成服务接口
*
* @author ziin
*/
public interface ElevenLabsService {
/**
* 将文本转换为语音(带时间戳)
*
* @param text 要转换的文本
* @return 语音合成结果,包含 base64 音频
*/
TextToSpeechVO textToSpeechWithTimestamps(String text);
/**
* 将文本转换为语音(带时间戳),使用指定语音
*
* @param text 要转换的文本
* @param voiceId 语音 ID
* @return 语音合成结果
*/
TextToSpeechVO textToSpeechWithTimestamps(String text, String voiceId);
}

View File

@@ -0,0 +1,36 @@
package com.yolo.keyborad.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
import java.util.List;
/*
* @author: ziin
* @date: 2026/1/26 17:00
*/
public interface KeyboardAiChatMessageService extends IService<KeyboardAiChatMessage> {
/**
* 分页查询用户与AI的聊天记录
*
* @param userId 用户ID
* @param companionId AI陪聊角色ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 分页结果
*/
IPage<ChatMessageHistoryVO> pageHistory(Long userId, Long companionId, Integer pageNum, Integer pageSize);
/**
* 获取最近的聊天记录按时间正序用于LLM上下文
*
* @param userId 用户ID
* @param companionId AI陪聊角色ID
* @param limit 获取条数
* @return 聊天记录列表(时间正序)
*/
List<KeyboardAiChatMessage> getRecentMessages(Long userId, Long companionId, int limit);
}

View File

@@ -0,0 +1,41 @@
package com.yolo.keyborad.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionCommentLike;
import java.util.List;
import java.util.Set;
/*
* @author: ziin
* @date: 2026/1/26
*/
public interface KeyboardAiCompanionCommentLikeService extends IService<KeyboardAiCompanionCommentLike> {
/**
* 检查用户是否已点赞该评论
*
* @param userId 用户ID
* @param commentId 评论ID
* @return 是否已点赞
*/
boolean hasLiked(Long userId, Long commentId);
/**
* 批量检查用户是否已点赞评论
*
* @param userId 用户ID
* @param commentIds 评论ID列表
* @return 已点赞的评论ID集合
*/
Set<Long> getLikedCommentIds(Long userId, List<Long> commentIds);
/**
* 点赞或取消点赞评论
*
* @param userId 用户ID
* @param commentId 评论ID
* @return true=点赞成功false=取消点赞成功
*/
boolean toggleLike(Long userId, Long commentId);
}

View File

@@ -0,0 +1,46 @@
package com.yolo.keyborad.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionComment;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.vo.CommentVO;
/*
* @author: ziin
* @date: 2026/1/26 20:31
*/
public interface KeyboardAiCompanionCommentService extends IService<KeyboardAiCompanionComment> {
/**
* 发表评论
*
* @param userId 用户ID
* @param companionId AI陪伴角色ID
* @param content 评论内容
* @param parentId 父评论ID可为null
* @param rootId 根评论ID可为null
* @return 评论ID
*/
Long addComment(Long userId, Long companionId, String content, Long parentId, Long rootId);
/**
* 分页查询评论
*
* @param companionId AI陪伴角色ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 分页结果
*/
IPage<CommentVO> pageComments(Long companionId, Integer pageNum, Integer pageSize);
/**
* 分页查询评论(带用户点赞状态)
*
* @param userId 当前用户ID
* @param companionId AI陪伴角色ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 分页结果
*/
IPage<CommentVO> pageCommentsWithLikeStatus(Long userId, Long companionId, Integer pageNum, Integer pageSize);
}

View File

@@ -0,0 +1,30 @@
package com.yolo.keyborad.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.vo.AiCompanionVO;
/*
* @author: ziin
* @date: 2026/1/26 13:51
*/
public interface KeyboardAiCompanionService extends IService<KeyboardAiCompanion> {
/**
* 分页查询已上线的AI陪聊角色
*
* @param pageNum 页码
* @param pageSize 每页数量
* @return 分页结果
*/
IPage<AiCompanionVO> pageList(Integer pageNum, Integer pageSize);
/**
* 根据AI人设ID获取系统提示词
*
* @param companionId AI人设ID
* @return 系统提示词
*/
String getSystemPromptById(Long companionId);
}

View File

@@ -10,25 +10,47 @@ import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.chat.ChatReq;
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
import com.yolo.keyborad.model.entity.KeyboardCharacter;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
import com.yolo.keyborad.model.vo.AudioTaskVO;
import com.yolo.keyborad.model.vo.ChatMessageVO;
import com.yolo.keyborad.model.vo.ChatVoiceVO;
import com.yolo.keyborad.model.vo.TextToSpeechVO;
import com.yolo.keyborad.service.*;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.dromara.x.file.storage.core.FileInfo;
import org.dromara.x.file.storage.core.FileStorageService;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.math.BigDecimal;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@@ -61,6 +83,26 @@ public class ChatServiceImpl implements ChatService {
@Resource
private UserService userService;
@Resource
private KeyboardAiCompanionService aiCompanionService;
@Resource
private KeyboardAiChatMessageService aiChatMessageService;
@Resource
private ElevenLabsService elevenLabsService;
@Resource
private FileStorageService fileStorageService;
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String AUDIO_TASK_PREFIX = "audio:task:";
private static final long AUDIO_TASK_EXPIRE_SECONDS = 3600; // 1小时过期
private static final String CHAT_DAILY_LIMIT_PREFIX = "chat:daily:limit:";
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
public ChatServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
@@ -321,4 +363,232 @@ public class ChatServiceImpl implements ChatService {
.build()
);
}
/**
* 同步对话(异步生成音频)
*
* @param content 用户消息内容
* @param userId 用户ID
* @param companionId AI陪聊角色ID
* @return AI 响应 + 音频任务 ID
*/
@Override
public ChatMessageVO message(String content, String userId, Long companionId) {
log.info("同步对话请求, userId: {}, companionId: {}, content: {}", userId, companionId, content);
// ============ 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;
// 获取当前使用次数
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();
// 获取AI人设的系统提示词
String systemPrompt = aiCompanionService.getSystemPromptById(companionId);
// 获取最近20条聊天记录作为上下文
List<KeyboardAiChatMessage> historyMessages = aiChatMessageService.getRecentMessages(
Long.parseLong(userId), companionId, 20);
log.info("获取历史消息, userId: {}, companionId: {}, 数量: {}", userId, companionId, historyMessages.size());
String response = callLLMWithHistory(content, systemPrompt, historyMessages);
long llmDuration = System.currentTimeMillis() - startTime;
log.info("LLM 完成, userId: {}, 耗时: {}ms, 响应长度: {}", userId, llmDuration, response.length());
// 保存用户消息和AI响应到聊天记录
saveChatMessages(Long.parseLong(userId), companionId, content, response);
// 生成音频任务 ID
String audioId = UUID.randomUUID().toString().replace("-", "");
// 初始化音频任务状态为 processing
setAudioTaskStatus(audioId, AudioTaskVO.STATUS_PROCESSING, null, null);
// 异步执行 TTS + R2 上传
CompletableFuture.runAsync(() -> processAudioAsync(audioId, response, userId));
return ChatMessageVO.builder()
.aiResponse(response)
.audioId(audioId)
.llmDuration(llmDuration)
.build();
}
/**
* 保存用户消息和AI响应到聊天记录
*/
private void saveChatMessages(Long userId, Long companionId, String userContent, String aiResponse) {
Date now = new Date();
// 保存用户消息 (sender=1)
KeyboardAiChatMessage userMessage = new KeyboardAiChatMessage();
userMessage.setUserId(userId);
userMessage.setCompanionId(companionId);
userMessage.setSender((short) 1);
userMessage.setContent(userContent);
userMessage.setCreatedAt(now);
// 保存AI响应 (sender=2)
KeyboardAiChatMessage aiMessage = new KeyboardAiChatMessage();
aiMessage.setUserId(userId);
aiMessage.setCompanionId(companionId);
aiMessage.setSender((short) 2);
aiMessage.setContent(aiResponse);
aiMessage.setCreatedAt(now);
// 批量保存
aiChatMessageService.saveBatch(List.of(userMessage, aiMessage));
log.info("聊天记录保存成功, userId: {}, companionId: {}", userId, companionId);
}
/**
* 调用 LLM 生成响应(带历史消息上下文)
*/
private String callLLMWithHistory(String content, String systemPrompt, List<KeyboardAiChatMessage> historyMessages) {
// 构建历史消息列表
List<Message> messages = new ArrayList<>();
for (KeyboardAiChatMessage msg : historyMessages) {
if (msg.getSender() == 1) {
// 用户消息
messages.add(new UserMessage(msg.getContent()));
} else {
// AI消息
messages.add(new AssistantMessage(msg.getContent()));
}
}
// 添加当前用户消息
messages.add(new UserMessage(content));
return client
.prompt()
.system(systemPrompt)
.messages(messages)
.call()
.content();
}
/**
* 异步处理音频TTS 转换 + 上传 R2
*/
private void processAudioAsync(String audioId, String text, String userId) {
try {
log.info("开始异步音频处理, audioId: {}", audioId);
long startTime = System.currentTimeMillis();
// 1. TTS 转换
long ttsStart = System.currentTimeMillis();
TextToSpeechVO ttsResult = elevenLabsService.textToSpeechWithTimestamps(text);
long ttsDuration = System.currentTimeMillis() - ttsStart;
log.info("TTS 完成, audioId: {}, 耗时: {}ms", audioId, ttsDuration);
// 2. 上传到 R2
long uploadStart = System.currentTimeMillis();
String audioUrl = uploadAudioToR2(ttsResult.getAudioBase64(), userId);
long uploadDuration = System.currentTimeMillis() - uploadStart;
log.info("R2 上传完成, audioId: {}, 耗时: {}ms, URL: {}", audioId, uploadDuration, audioUrl);
// 3. 更新任务状态为完成
setAudioTaskStatus(audioId, AudioTaskVO.STATUS_COMPLETED, audioUrl, null);
long totalDuration = System.currentTimeMillis() - startTime;
log.info("异步音频处理完成, audioId: {}, 总耗时: {}ms (TTS: {}ms, Upload: {}ms)",
audioId, totalDuration, ttsDuration, uploadDuration);
} catch (Exception e) {
log.error("异步音频处理失败, audioId: {}", audioId, e);
setAudioTaskStatus(audioId, AudioTaskVO.STATUS_FAILED, null, e.getMessage());
}
}
/**
* 设置音频任务状态
*/
private void setAudioTaskStatus(String audioId, String status, String audioUrl, String errorMessage) {
String key = AUDIO_TASK_PREFIX + audioId;
String value = status + "|" + (audioUrl != null ? audioUrl : "") + "|" + (errorMessage != null ? errorMessage : "");
stringRedisTemplate.opsForValue().set(key, value, AUDIO_TASK_EXPIRE_SECONDS, TimeUnit.SECONDS);
}
/**
* 查询音频任务状态
*/
@Override
public AudioTaskVO getAudioTask(String audioId) {
String key = AUDIO_TASK_PREFIX + audioId;
String value = stringRedisTemplate.opsForValue().get(key);
if (cn.hutool.core.util.StrUtil.isBlank(value)) {
return AudioTaskVO.builder()
.audioId(audioId)
.status(AudioTaskVO.STATUS_PENDING)
.build();
}
String[] parts = value.split("\\|", -1);
return AudioTaskVO.builder()
.audioId(audioId)
.status(parts[0])
.audioUrl(parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null)
.errorMessage(parts.length > 2 && !parts[2].isEmpty() ? parts[2] : null)
.build();
}
/**
* 上传音频到 R2
*/
private String uploadAudioToR2(String audioBase64, String userId) {
if (cn.hutool.core.util.StrUtil.isBlank(audioBase64)) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "音频数据为空");
}
byte[] audioBytes = Base64.getDecoder().decode(audioBase64);
String fileName = IdUtil.fastSimpleUUID() + ".mp3";
FileInfo fileInfo = fileStorageService.of(new ByteArrayInputStream(audioBytes))
.setPath(userId + "/")
.setPlatform("cloudflare-r2")
.setSaveFilename(fileName)
.setOriginalFilename(fileName)
.upload();
if (fileInfo == null || cn.hutool.core.util.StrUtil.isBlank(fileInfo.getUrl())) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "音频上传失败");
}
return fileInfo.getUrl();
}
}

View File

@@ -0,0 +1,111 @@
package com.yolo.keyborad.service.impl;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.config.ElevenLabsProperties;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.vo.TextToSpeechVO;
import com.yolo.keyborad.service.ElevenLabsService;
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 java.util.HashMap;
import java.util.Map;
/**
* ElevenLabs TTS 语音合成服务实现
* 参考: https://elevenlabs.io/docs/api-reference/text-to-speech/convert-with-timestamps
*
* @author ziin
*/
@Service
@Slf4j
public class ElevenLabsServiceImpl implements ElevenLabsService {
@Resource
private ElevenLabsProperties elevenLabsProperties;
@Resource
private RestClient restClient;
private static final int MAX_TEXT_LENGTH = 5000;
@Override
public TextToSpeechVO textToSpeechWithTimestamps(String text) {
return textToSpeechWithTimestamps(text, elevenLabsProperties.getVoiceId());
}
@Override
public TextToSpeechVO textToSpeechWithTimestamps(String text, String voiceId) {
if (StrUtil.isBlank(text)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文本内容不能为空");
}
if (text.length() > MAX_TEXT_LENGTH) {
throw new BusinessException(ErrorCode.PARAMS_ERROR,
"文本长度超出限制,最大支持 " + MAX_TEXT_LENGTH + " 字符");
}
if (StrUtil.isBlank(voiceId)) {
voiceId = elevenLabsProperties.getVoiceId();
}
String requestUrl = buildRequestUrl(voiceId);
Map<String, Object> requestBody = buildRequestBody(text);
log.info("调用 ElevenLabs TTS API, voiceId: {}, 文本长度: {}", voiceId, text.length());
long startTime = System.currentTimeMillis();
try {
String responseJson = restClient.post()
.uri(requestUrl)
.contentType(MediaType.APPLICATION_JSON)
.header("xi-api-key", elevenLabsProperties.getApiKey())
.body(requestBody)
.retrieve()
.body(String.class);
long duration = System.currentTimeMillis() - startTime;
log.info("ElevenLabs TTS API 响应成功, 耗时: {}ms", duration);
JSONObject jsonResponse = JSONObject.parseObject(responseJson);
String audioBase64 = jsonResponse.getString("audio_base64");
log.info("语音合成成功Base64长度: {}", audioBase64.length());
return TextToSpeechVO.builder()
.audioBase64(audioBase64)
.build();
} catch (Exception e) {
log.error("调用 ElevenLabs TTS API 发生异常", e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "语音合成服务异常: " + e.getMessage());
}
}
private String buildRequestUrl(String voiceId) {
return elevenLabsProperties.getBaseUrl() +
"/text-to-speech/" + voiceId + "/with-timestamps" +
"?output_format=" + elevenLabsProperties.getOutputFormat();
}
private Map<String, Object> buildRequestBody(String text) {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("text", text);
requestBody.put("model_id", elevenLabsProperties.getModelId());
Map<String, Object> voiceSettings = new HashMap<>();
voiceSettings.put("stability", elevenLabsProperties.getStability());
voiceSettings.put("similarity_boost", elevenLabsProperties.getSimilarityBoost());
voiceSettings.put("style", elevenLabsProperties.getStyle());
voiceSettings.put("speed", elevenLabsProperties.getSpeed());
voiceSettings.put("use_speaker_boost", elevenLabsProperties.getUseSpeakerBoost());
requestBody.put("voice_settings", voiceSettings);
return requestBody;
}
}

View File

@@ -0,0 +1,47 @@
package com.yolo.keyborad.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
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.vo.ChatMessageHistoryVO;
import com.yolo.keyborad.service.KeyboardAiChatMessageService;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
/*
* @author: ziin
* @date: 2026/1/26 17:00
*/
@Service
public class KeyboardAiChatMessageServiceImpl extends ServiceImpl<KeyboardAiChatMessageMapper, KeyboardAiChatMessage> implements KeyboardAiChatMessageService {
@Override
public IPage<ChatMessageHistoryVO> pageHistory(Long userId, Long companionId, Integer pageNum, Integer pageSize) {
Page<KeyboardAiChatMessage> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<KeyboardAiChatMessage> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiChatMessage::getUserId, userId)
.eq(KeyboardAiChatMessage::getCompanionId, companionId)
.orderByDesc(KeyboardAiChatMessage::getCreatedAt);
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<KeyboardAiChatMessage> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiChatMessage::getUserId, userId)
.eq(KeyboardAiChatMessage::getCompanionId, companionId)
.orderByDesc(KeyboardAiChatMessage::getCreatedAt)
.last("LIMIT " + limit);
List<KeyboardAiChatMessage> messages = this.list(queryWrapper);
// 反转列表,使其按时间正序排列(旧消息在前)
Collections.reverse(messages);
return messages;
}
}

View File

@@ -0,0 +1,111 @@
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.KeyboardAiCompanionCommentLikeMapper;
import com.yolo.keyborad.mapper.KeyboardAiCompanionCommentMapper;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionComment;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionCommentLike;
import com.yolo.keyborad.service.KeyboardAiCompanionCommentLikeService;
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/26
*/
@Service
public class KeyboardAiCompanionCommentLikeServiceImpl extends ServiceImpl<KeyboardAiCompanionCommentLikeMapper, KeyboardAiCompanionCommentLike> implements KeyboardAiCompanionCommentLikeService {
@Resource
private KeyboardAiCompanionCommentMapper commentMapper;
@Override
public boolean hasLiked(Long userId, Long commentId) {
LambdaQueryWrapper<KeyboardAiCompanionCommentLike> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanionCommentLike::getUserId, userId)
.eq(KeyboardAiCompanionCommentLike::getCommentId, commentId)
.eq(KeyboardAiCompanionCommentLike::getStatus, (short) 1);
return this.count(queryWrapper) > 0;
}
@Override
public Set<Long> getLikedCommentIds(Long userId, List<Long> commentIds) {
if (commentIds == null || commentIds.isEmpty()) {
return new HashSet<>();
}
LambdaQueryWrapper<KeyboardAiCompanionCommentLike> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanionCommentLike::getUserId, userId)
.in(KeyboardAiCompanionCommentLike::getCommentId, commentIds)
.eq(KeyboardAiCompanionCommentLike::getStatus, (short) 1);
List<KeyboardAiCompanionCommentLike> likes = this.list(queryWrapper);
return likes.stream()
.map(KeyboardAiCompanionCommentLike::getCommentId)
.collect(Collectors.toSet());
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean toggleLike(Long userId, Long commentId) {
// 检查评论是否存在
KeyboardAiCompanionComment comment = commentMapper.selectById(commentId);
if (comment == null || comment.getStatus() != 1) {
throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND);
}
// 查找现有点赞记录
LambdaQueryWrapper<KeyboardAiCompanionCommentLike> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanionCommentLike::getUserId, userId)
.eq(KeyboardAiCompanionCommentLike::getCommentId, commentId);
KeyboardAiCompanionCommentLike 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);
// 减少点赞数
comment.setLikeCount(Math.max(0, comment.getLikeCount() - 1));
isLiked = false;
} else {
// 重新点赞
existingLike.setStatus((short) 1);
existingLike.setUpdatedAt(now);
this.updateById(existingLike);
// 增加点赞数
comment.setLikeCount(comment.getLikeCount() + 1);
isLiked = true;
}
} else {
// 新增点赞记录
KeyboardAiCompanionCommentLike like = new KeyboardAiCompanionCommentLike();
like.setUserId(userId);
like.setCommentId(commentId);
like.setStatus((short) 1);
like.setCreatedAt(now);
like.setUpdatedAt(now);
this.save(like);
// 增加点赞数
comment.setLikeCount(comment.getLikeCount() + 1);
isLiked = true;
}
// 更新评论点赞数
commentMapper.updateById(comment);
return isLiked;
}
}

View File

@@ -0,0 +1,149 @@
package com.yolo.keyborad.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardAiCompanionCommentMapper;
import com.yolo.keyborad.model.entity.KeyboardAiCompanionComment;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.CommentVO;
import com.yolo.keyborad.service.KeyboardAiCompanionCommentService;
import com.yolo.keyborad.service.KeyboardAiCompanionCommentLikeService;
import com.yolo.keyborad.service.UserService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/*
* @author: ziin
* @date: 2026/1/26 20:31
*/
@Service
public class KeyboardAiCompanionCommentServiceImpl extends ServiceImpl<KeyboardAiCompanionCommentMapper, KeyboardAiCompanionComment> implements KeyboardAiCompanionCommentService {
@Resource
private UserService userService;
@Resource
private KeyboardAiCompanionCommentLikeService commentLikeService;
@Override
public Long addComment(Long userId, Long companionId, String content, Long parentId, Long rootId) {
KeyboardAiCompanionComment comment = new KeyboardAiCompanionComment();
comment.setUserId(userId);
comment.setCompanionId(companionId);
comment.setContent(content);
comment.setParentId(parentId);
comment.setRootId(rootId);
comment.setStatus((short) 1);
comment.setLikeCount(0);
comment.setCreatedAt(new Date());
this.save(comment);
return comment.getId();
}
@Override
public IPage<CommentVO> pageComments(Long companionId, Integer pageNum, Integer pageSize) {
Page<KeyboardAiCompanionComment> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<KeyboardAiCompanionComment> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanionComment::getCompanionId, companionId)
.eq(KeyboardAiCompanionComment::getStatus, 1)
.isNull(KeyboardAiCompanionComment::getParentId)
.orderByDesc(KeyboardAiCompanionComment::getCreatedAt);
IPage<KeyboardAiCompanionComment> entityPage = this.page(page, queryWrapper);
// 获取所有用户ID
List<Long> userIds = entityPage.getRecords().stream()
.map(KeyboardAiCompanionComment::getUserId)
.distinct()
.collect(Collectors.toList());
// 批量查询用户信息
Map<Long, KeyboardUser> userMap = Map.of();
if (!userIds.isEmpty()) {
List<KeyboardUser> users = userService.listByIds(userIds);
userMap = users.stream().collect(Collectors.toMap(KeyboardUser::getId, u -> u));
}
// 转换为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());
// 填充用户信息
KeyboardUser user = finalUserMap.get(entity.getUserId());
if (user != null) {
vo.setUserName(user.getNickName());
vo.setUserAvatar(user.getAvatarUrl());
}
return vo;
});
}
@Override
public IPage<CommentVO> pageCommentsWithLikeStatus(Long userId, Long companionId, Integer pageNum, Integer pageSize) {
Page<KeyboardAiCompanionComment> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<KeyboardAiCompanionComment> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiCompanionComment::getCompanionId, companionId)
.eq(KeyboardAiCompanionComment::getStatus, 1)
.isNull(KeyboardAiCompanionComment::getParentId)
.orderByDesc(KeyboardAiCompanionComment::getCreatedAt);
IPage<KeyboardAiCompanionComment> entityPage = this.page(page, queryWrapper);
// 获取所有用户ID
List<Long> userIds = entityPage.getRecords().stream()
.map(KeyboardAiCompanionComment::getUserId)
.distinct()
.collect(Collectors.toList());
// 批量查询用户信息
Map<Long, KeyboardUser> userMap = Map.of();
if (!userIds.isEmpty()) {
List<KeyboardUser> users = userService.listByIds(userIds);
userMap = users.stream().collect(Collectors.toMap(KeyboardUser::getId, u -> u));
}
// 获取当前用户已点赞的评论ID
List<Long> commentIds = entityPage.getRecords().stream()
.map(KeyboardAiCompanionComment::getId)
.collect(Collectors.toList());
Set<Long> likedCommentIds = commentLikeService.getLikedCommentIds(userId, commentIds);
// 转换为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()));
// 填充用户信息
KeyboardUser user = finalUserMap.get(entity.getUserId());
if (user != null) {
vo.setUserName(user.getNickName());
vo.setUserAvatar(user.getAvatarUrl());
}
return vo;
});
}
}

View File

@@ -0,0 +1,46 @@
package com.yolo.keyborad.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.vo.AiCompanionVO;
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;
/*
* @author: ziin
* @date: 2026/1/26 13:51
*/
@Service
public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompanionMapper, KeyboardAiCompanion> implements KeyboardAiCompanionService {
@Override
public IPage<AiCompanionVO> pageList(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);
return entityPage.convert(entity -> BeanUtil.copyProperties(entity, AiCompanionVO.class));
}
@Override
public String getSystemPromptById(Long companionId) {
KeyboardAiCompanion companion = this.getById(companionId);
if (companion == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "AI陪聊角色不存在");
}
if (companion.getStatus() != 1) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "AI陪聊角色已下线");
}
return companion.getSystemPrompt();
}
}

View File

@@ -68,6 +68,15 @@ dromara:
bucket-name: keyborad-resource #桶名称
domain: https://resource.loveamorkey.com/ # 访问域名,注意末尾的'/'例如https://abcd.s3.ap-east-1.amazonaws.com/
base-path: avatar/ # 基础路径
- platform: cloudflare-r2-apac # 存储平台标识
enable-storage: true # 启用存储
access-key: eda135fe4fda649acecfa4bb49b0c30c
secret-key: ee557acaccf44caef985b5cac89db311a0923c72c9f4b8c5f32089c6ebb47a79
region: APAC # 区域
end-point: https://b632a61caa85401f63c9b32eef3a74c8.r2.cloudflarestorage.com/keyboardtest # 端点
bucket-name: keyboardtest #桶名称
domain: https://cdn.loveamorkey.com/ # 访问域名,注意末尾的'/'例如https://abcd.s3.ap-east-1.amazonaws.com/
base-path: tts/ # 基础路径
############## Sa-Token 配置 (参考文档: https://sa-token.cc) ##############
sa-token:
@@ -91,3 +100,9 @@ nacos:
server-addr: 127.0.0.1:8848
group: DEFAULT_GROUP
data-id: keyboard_default-config.yaml
elevenlabs:
api-key: sk_25339d32bb14c91f460ed9fce83a1951672f07846a7a10ce
voice-id: JBFqnCBsd6RMkjVDRZzb
model-id: eleven_turbo_v2_5
output-format: mp3_44100_128

View File

@@ -1,13 +1,13 @@
spring:
ai:
openai:
# api-key: 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
api-key: sk-cf112f49cf4d4138a49575cda1f852b4
# base-url: https://gateway.ai.cloudflare.com/v1/b632a61caa85401f63c9b32eef3a74c8/aigetway/openrouter
base-url: https://dashscope-intl.aliyuncs.com/compatible-mode/
api-key: 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
# api-key: sk-cf112f49cf4d4138a49575cda1f852b4
base-url: https://gateway.ai.cloudflare.com/v1/b632a61caa85401f63c9b32eef3a74c8/aigetway/openrouter
# base-url: https://dashscope-intl.aliyuncs.com/compatible-mode/
chat:
options:
model: qwen-plus
model: google/gemini-2.5-flash-lite
embedding:
options:
model: text-embedding-v4

View File

@@ -0,0 +1,20 @@
<?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.KeyboardAiChatMessageMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardAiChatMessage">
<!--@mbg.generated-->
<!--@Table keyboard_ai_chat_message-->
<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="sender" jdbcType="SMALLINT" property="sender" />
<result column="content" jdbcType="VARCHAR" property="content" />
<result column="emotion_detected" jdbcType="VARCHAR" property="emotionDetected" />
<result column="support_type" jdbcType="VARCHAR" property="supportType" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, companion_id, sender, content, emotion_detected, support_type, created_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.KeyboardAiCompanionCommentLikeMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardAiCompanionCommentLike">
<!--@mbg.generated-->
<!--@Table keyboard_ai_companion_comment_like-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="comment_id" jdbcType="BIGINT" property="commentId" />
<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, comment_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.KeyboardAiCompanionCommentMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardAiCompanionComment">
<!--@mbg.generated-->
<!--@Table keyboard_ai_companion_comment-->
<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="parent_id" jdbcType="BIGINT" property="parentId" />
<result column="root_id" jdbcType="BIGINT" property="rootId" />
<result column="content" jdbcType="VARCHAR" property="content" />
<result column="like" jdbcType="BIGINT" property="like" />
<result column="status" jdbcType="SMALLINT" property="status" />
<result column="like_count" jdbcType="INTEGER" property="likeCount" />
<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, parent_id, root_id, content, "like", "status", like_count,
created_at, updated_at
</sql>
</mapper>

View File

@@ -0,0 +1,31 @@
<?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.KeyboardAiCompanionMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardAiCompanion">
<!--@mbg.generated-->
<!--@Table keyboard_ai_companion-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="avatar_url" jdbcType="VARCHAR" property="avatarUrl" />
<result column="cover_image_url" jdbcType="VARCHAR" property="coverImageUrl" />
<result column="gender" jdbcType="VARCHAR" property="gender" />
<result column="age_range" jdbcType="VARCHAR" property="ageRange" />
<result column="short_desc" jdbcType="VARCHAR" property="shortDesc" />
<result column="intro_text" jdbcType="VARCHAR" property="introText" />
<result column="personality_tags" jdbcType="VARCHAR" property="personalityTags" />
<result column="speaking_style" jdbcType="VARCHAR" property="speakingStyle" />
<result column="system_prompt" jdbcType="VARCHAR" property="systemPrompt" />
<result column="status" jdbcType="SMALLINT" property="status" />
<result column="visibility" jdbcType="SMALLINT" property="visibility" />
<result column="sort_order" jdbcType="INTEGER" property="sortOrder" />
<result column="popularity_score" jdbcType="INTEGER" property="popularityScore" />
<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, "name", avatar_url, cover_image_url, gender, age_range, short_desc, intro_text,
personality_tags, speaking_style, system_prompt, "status", visibility, sort_order,
popularity_score, created_at, updated_at
</sql>
</mapper>