Compare commits
37 Commits
e90078791c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 392d9ecfe8 | |||
| 6a773ee0ca | |||
| 7d23b6be0f | |||
| 408d4d4bc1 | |||
| ecab353802 | |||
| 234ea0c241 | |||
| e1aa1ce4e8 | |||
| c8d8046bf4 | |||
| 0e863288c8 | |||
| f28e6b7c39 | |||
| 22e5041447 | |||
| e68f1bea56 | |||
| b6d124619e | |||
| 6cf0275980 | |||
| f18217ba93 | |||
| 1decf0ac58 | |||
| 1523ea0fbd | |||
| 5a58c4ff38 | |||
| ac9a352004 | |||
| aaf5d3bea4 | |||
| b887e52f55 | |||
| 6bb905bb30 | |||
| fd4c381d33 | |||
| 8783a4c2af | |||
| 6a1bb50318 | |||
| bb3dcc56ff | |||
| 8632dcd282 | |||
| 090cb65c0b | |||
| a73a92c0c2 | |||
| 2cdbdfeaf2 | |||
| a510a4afcb | |||
| c38f62c3c1 | |||
| be921e144f | |||
| 778cf4a0cb | |||
| fb0c0c34a9 | |||
| 6ef1488e5f | |||
| b9197c4275 |
457
.claude/CLAUDE.md
Normal file
457
.claude/CLAUDE.md
Normal 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.
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -38,3 +38,12 @@ build/
|
|||||||
/.claude/agents/backend-architect.md
|
/.claude/agents/backend-architect.md
|
||||||
/.dockerignore
|
/.dockerignore
|
||||||
/Dockerfile
|
/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/
|
||||||
|
|||||||
7
.omc/ultrawork-state.json
Normal file
7
.omc/ultrawork-state.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"started_at": "2026-01-26T13:01:18.447Z",
|
||||||
|
"original_prompt": "刚刚回滚了代码,现在AI陪聊角色评论需要使用KeyboardAiCompanionCommentLikeService添加一个评论点赞接口,用来记录点赞和取消点赞。 ulw",
|
||||||
|
"reinforcement_count": 10,
|
||||||
|
"last_checked_at": "2026-01-27T11:00:42.142Z"
|
||||||
|
}
|
||||||
141
README.md
141
README.md
@@ -1,26 +1,123 @@
|
|||||||
# SpringBoot 项目初始模板
|
# Keyborad Backend
|
||||||
|
|
||||||
> Java SpringBoot 项目初始模板,整合了常用框架和示例代码,大家可以在此基础上快速开发自己的项目。
|
基于 Spring Boot 3.5.5 的后端服务,集成了 AI 能力、向量搜索、Apple 登录等功能。
|
||||||
|
|
||||||
## 模板功能
|
## 技术栈
|
||||||
|
|
||||||
- Spring Boot 2.7.0(贼新)
|
- **Java 17** + **Spring Boot 3.5.5**
|
||||||
- Spring MVC
|
- **Spring AI** - LLM 对话和文本嵌入(OpenAI 兼容 API)
|
||||||
- MySQL 驱动
|
- **Qdrant** - 向量数据库,支持语义搜索
|
||||||
- MyBatis
|
- **PostgreSQL** - 关系型数据库
|
||||||
- MyBatis Plus
|
- **MyBatis Plus** - ORM 框架
|
||||||
- Spring Session Redis 分布式登录
|
- **Redis** - 会话存储和缓存
|
||||||
- Spring AOP
|
- **Sa-Token** - 认证授权框架
|
||||||
- Apache Commons Lang3 工具类
|
- **Knife4j** - API 文档
|
||||||
- Lombok 注解
|
- **X-File-Storage** - 文件上传(Cloudflare R2)
|
||||||
- Swagger + Knife4j 接口文档
|
- **MailerSend** - 邮件服务
|
||||||
- Spring Boot 调试工具和项目处理器
|
|
||||||
- 全局请求响应拦截器(记录日志)
|
|
||||||
- 全局异常处理器
|
|
||||||
- 自定义错误码
|
|
||||||
- 封装通用响应类
|
|
||||||
- 示例用户注册、登录、搜索功能
|
|
||||||
- 示例单元测试类
|
|
||||||
- 示例 SQL(用户表)
|
|
||||||
|
|
||||||
访问 localhost:7529/api/doc.html 就能在线调试接口了,不需要前端配合啦~
|
## 核心功能
|
||||||
|
|
||||||
|
### 认证系统
|
||||||
|
- Apple Sign-In JWT 验证
|
||||||
|
- Sa-Token 会话管理
|
||||||
|
- 请求签名校验(防篡改/防重放)
|
||||||
|
|
||||||
|
### AI 能力
|
||||||
|
- LLM 对话(支持流式响应)
|
||||||
|
- 文本嵌入(1536 维向量)
|
||||||
|
- 语义搜索(Qdrant 向量检索)
|
||||||
|
|
||||||
|
### 通用功能
|
||||||
|
- 统一响应格式
|
||||||
|
- 全局异常处理
|
||||||
|
- 国际化支持(i18n)
|
||||||
|
- 请求日志记录
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- JDK 17+
|
||||||
|
- Maven 3.8+
|
||||||
|
- PostgreSQL 14+
|
||||||
|
- Redis 6+
|
||||||
|
|
||||||
|
### 本地运行
|
||||||
|
|
||||||
|
1. 克隆项目
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd keyborad-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 配置数据库和 Redis
|
||||||
|
```yaml
|
||||||
|
# 修改 src/main/resources/application-dev.yml
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://localhost:5432/keyborad_db
|
||||||
|
username: your_username
|
||||||
|
password: your_password
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 启动应用
|
||||||
|
```bash
|
||||||
|
mvn spring-boot:run
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 访问 API 文档
|
||||||
|
```
|
||||||
|
http://localhost:7529/api/doc.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/java/com/yolo/keyborad/
|
||||||
|
├── controller/ # REST API 端点
|
||||||
|
├── service/ # 业务逻辑层
|
||||||
|
│ └── impl/ # 服务实现
|
||||||
|
├── mapper/ # MyBatis 数据库映射
|
||||||
|
├── model/
|
||||||
|
│ ├── entity/ # 数据库实体
|
||||||
|
│ ├── dto/ # 请求数据传输对象
|
||||||
|
│ └── vo/ # 响应视图对象
|
||||||
|
├── config/ # Spring 配置类
|
||||||
|
├── aop/ # AOP 拦截器
|
||||||
|
├── Interceptor/ # 请求拦截器
|
||||||
|
├── filter/ # Servlet 过滤器
|
||||||
|
├── exception/ # 异常处理
|
||||||
|
├── common/ # 通用工具类
|
||||||
|
└── utils/ # 工具类
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 默认值 |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `server.port` | 服务端口 | 7529 |
|
||||||
|
| `server.servlet.context-path` | 上下文路径 | /api |
|
||||||
|
| `spring.profiles.active` | 激活配置文件 | dev |
|
||||||
|
|
||||||
|
## API 认证
|
||||||
|
|
||||||
|
### Sa-Token 认证
|
||||||
|
需要在请求头中携带 `satoken` 字段。
|
||||||
|
|
||||||
|
### 请求签名
|
||||||
|
部分接口需要签名校验,请求头需包含:
|
||||||
|
- `X-App-Id` - 应用 ID
|
||||||
|
- `X-Timestamp` - 时间戳
|
||||||
|
- `X-Nonce` - 随机数
|
||||||
|
- `X-Sign` - 签名
|
||||||
|
|
||||||
|
## 开发指南
|
||||||
|
|
||||||
|
详细的开发指南请参考 [CLAUDE.md](./CLAUDE.md)。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|||||||
21
pom.xml
21
pom.xml
@@ -280,6 +280,27 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>17</source>
|
||||||
|
<target>17</target>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.38</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.yolo.keyborad.Interceptor;
|
package com.yolo.keyborad.interceptor;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.yolo.keyborad.utils.SignUtils;
|
import com.yolo.keyborad.utils.SignUtils;
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ public enum ErrorCode {
|
|||||||
CHAT_CHARACTER_NOT_FOUND(40008, "键盘人设不存在"),
|
CHAT_CHARACTER_NOT_FOUND(40008, "键盘人设不存在"),
|
||||||
CHAT_MESSAGE_TOO_LONG(40009, "聊天消息过长,最大支持1000字符"),
|
CHAT_MESSAGE_TOO_LONG(40009, "聊天消息过长,最大支持1000字符"),
|
||||||
CHAT_SAVE_DATA_EMPTY(40010, "保存数据不能为空"),
|
CHAT_SAVE_DATA_EMPTY(40010, "保存数据不能为空"),
|
||||||
|
COMPANION_MESSAGE_EMPTY(40011, "消息内容不能为空"),
|
||||||
|
COMPANION_ID_EMPTY(40012, "AI陪聊角色ID不能为空"),
|
||||||
|
COMPANION_NOT_FOUND(40019, "AI陪聊角色不存在"),
|
||||||
|
COMMENT_CONTENT_EMPTY(40013, "评论内容不能为空"),
|
||||||
|
COMMENT_NOT_FOUND(40014, "评论不存在"),
|
||||||
|
COMMENT_ID_EMPTY(40015, "评论ID不能为空"),
|
||||||
TOKEN_NOT_FOUND(40102, "未能读取到有效用户令牌"),
|
TOKEN_NOT_FOUND(40102, "未能读取到有效用户令牌"),
|
||||||
TOKEN_INVALID(40103, "令牌无效"),
|
TOKEN_INVALID(40103, "令牌无效"),
|
||||||
TOKEN_TIMEOUT(40104, "令牌已过期"),
|
TOKEN_TIMEOUT(40104, "令牌已过期"),
|
||||||
@@ -64,7 +70,15 @@ public enum ErrorCode {
|
|||||||
INVITE_CODE_USED_UP(50026, "邀请码使用次数已达上限"),
|
INVITE_CODE_USED_UP(50026, "邀请码使用次数已达上限"),
|
||||||
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, "今日体验次数已达上限,请开通会员"),
|
||||||
|
AUDIO_FILE_EMPTY(40016, "音频文件不能为空"),
|
||||||
|
AUDIO_FILE_TOO_LARGE(40017, "音频文件过大"),
|
||||||
|
AUDIO_FORMAT_NOT_SUPPORTED(40018, "音频格式不支持"),
|
||||||
|
STT_SERVICE_ERROR(50031, "语音转文字服务异常"),
|
||||||
|
REPORT_TYPE_INVALID(40020, "举报类型无效"),
|
||||||
|
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
|
||||||
|
REPORT_TYPE_EMPTY(40022, "举报类型不能为空");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态码
|
* 状态码
|
||||||
|
|||||||
@@ -17,12 +17,17 @@ public class AppConfig {
|
|||||||
|
|
||||||
private LLmConfig llmConfig = new LLmConfig();
|
private LLmConfig llmConfig = new LLmConfig();
|
||||||
|
|
||||||
|
private inviteConfig inviteConfig = new inviteConfig();
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class UserRegisterProperties {
|
public static class UserRegisterProperties {
|
||||||
|
|
||||||
//新用户注册时的免费使用次数
|
//新用户注册时的免费使用次数
|
||||||
private Integer freeTrialQuota = 3;
|
private Integer freeTrialQuota = 3;
|
||||||
|
|
||||||
|
//Vip用户每天能免费聊天次数
|
||||||
|
private Integer vipFreeTrialTalk = 3;
|
||||||
|
|
||||||
//新用户注册时的奖励余额
|
//新用户注册时的奖励余额
|
||||||
private BigDecimal rewardBalance = BigDecimal.valueOf(0);
|
private BigDecimal rewardBalance = BigDecimal.valueOf(0);
|
||||||
}
|
}
|
||||||
@@ -48,4 +53,8 @@ public class AppConfig {
|
|||||||
private Integer maxMessageLength = 1000;
|
private Integer maxMessageLength = 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class inviteConfig {
|
||||||
|
private String h5Link = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ public class MyBatisPlusConfig {
|
|||||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
// 分页插件
|
// 分页插件
|
||||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
|
||||||
return interceptor;
|
return interceptor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
25
src/main/java/com/yolo/keyborad/config/RestClientConfig.java
Normal file
25
src/main/java/com/yolo/keyborad/config/RestClientConfig.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import cn.dev33.satoken.interceptor.SaInterceptor;
|
|||||||
import cn.dev33.satoken.router.SaHttpMethod;
|
import cn.dev33.satoken.router.SaHttpMethod;
|
||||||
import cn.dev33.satoken.router.SaRouter;
|
import cn.dev33.satoken.router.SaRouter;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.yolo.keyborad.Interceptor.SignInterceptor;
|
import com.yolo.keyborad.interceptor.SignInterceptor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -64,7 +64,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/demo/embed",
|
"/demo/embed",
|
||||||
"/demo/testSaveEmbed",
|
"/demo/testSaveEmbed",
|
||||||
"/demo/testSearch",
|
"/demo/testSearch",
|
||||||
"/demo/tsetSearchText",
|
"/demo/testSearchText",
|
||||||
"/file/upload",
|
"/file/upload",
|
||||||
"/user/logout",
|
"/user/logout",
|
||||||
"/tag/list",
|
"/tag/list",
|
||||||
@@ -73,6 +73,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/character/listByUser",
|
"/character/listByUser",
|
||||||
"/user/detail",
|
"/user/detail",
|
||||||
"/user/register",
|
"/user/register",
|
||||||
|
"/user/updateInfo",
|
||||||
"/character/updateUserCharacterSort",
|
"/character/updateUserCharacterSort",
|
||||||
"/character/delUserCharacter",
|
"/character/delUserCharacter",
|
||||||
"/user/sendVerifyMail",
|
"/user/sendVerifyMail",
|
||||||
@@ -107,7 +108,16 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/user/bindInviteCode",
|
"/user/bindInviteCode",
|
||||||
"/themes/listAllStyles",
|
"/themes/listAllStyles",
|
||||||
"/wallet/transactions",
|
"/wallet/transactions",
|
||||||
"/themes/restore"
|
"/themes/restore",
|
||||||
|
"/chat/message",
|
||||||
|
"/chat/voice",
|
||||||
|
"/chat/audio/*",
|
||||||
|
"/ai-companion/page",
|
||||||
|
"/chat/history",
|
||||||
|
"/ai-companion/comment/add",
|
||||||
|
"/speech/transcribe",
|
||||||
|
"/ai-companion/comment/page",
|
||||||
|
"/ai-companion/liked"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.yolo.keyborad.controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.yolo.keyborad.common.BaseResponse;
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.common.ResultUtils;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.model.dto.PageDTO;
|
||||||
|
import com.yolo.keyborad.model.dto.companion.CompanionLikeReq;
|
||||||
|
import com.yolo.keyborad.model.dto.companion.CompanionReportReq;
|
||||||
|
import com.yolo.keyborad.model.vo.AiCompanionVO;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionLikeService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionReportService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/26
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Slf4j
|
||||||
|
@RequestMapping("/ai-companion")
|
||||||
|
@Tag(name = "AI陪聊角色", description = "AI陪聊角色管理接口")
|
||||||
|
public class AiCompanionController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionService aiCompanionService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionLikeService aiCompanionLikeService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionReportService aiCompanionReportService;
|
||||||
|
|
||||||
|
@PostMapping("/page")
|
||||||
|
@Operation(summary = "分页查询AI陪聊角色", description = "分页查询已上线的AI陪聊角色列表,包含点赞数、评论数和当前用户点赞状态")
|
||||||
|
public BaseResponse<IPage<AiCompanionVO>> pageList(@RequestBody PageDTO pageDTO) {
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
IPage<AiCompanionVO> result = aiCompanionService.pageListWithLikeStatus(userId, pageDTO.getPageNum(), pageDTO.getPageSize());
|
||||||
|
return ResultUtils.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/like")
|
||||||
|
@Operation(summary = "点赞/取消点赞AI角色", description = "对AI角色进行点赞或取消点赞操作,返回true表示点赞成功,false表示取消点赞成功")
|
||||||
|
public BaseResponse<Boolean> toggleLike(@RequestBody CompanionLikeReq req) {
|
||||||
|
if (req.getCompanionId() == null) {
|
||||||
|
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
boolean result = aiCompanionLikeService.toggleLike(userId, req.getCompanionId());
|
||||||
|
return ResultUtils.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/liked")
|
||||||
|
@Operation(summary = "获取当前用户点赞过的AI角色列表", description = "查询当前用户点赞过的所有AI角色,返回角色详细信息")
|
||||||
|
public BaseResponse<List<AiCompanionVO>> getLikedCompanions() {
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
List<AiCompanionVO> result = aiCompanionService.getLikedCompanions(userId);
|
||||||
|
return ResultUtils.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/chatted")
|
||||||
|
@Operation(summary = "获取当前用户聊过天的AI角色列表", description = "查询当前用户聊过天的所有AI角色,返回角色详细信息")
|
||||||
|
public BaseResponse<List<AiCompanionVO>> getChattedCompanions() {
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
List<AiCompanionVO> result = aiCompanionService.getChattedCompanions(userId);
|
||||||
|
return ResultUtils.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{companionId}")
|
||||||
|
@Operation(summary = "根据ID获取AI角色详情", description = "根据AI角色ID查询角色详细信息,包含点赞数、评论数和当前用户点赞状态")
|
||||||
|
public BaseResponse<AiCompanionVO> getCompanionById(@PathVariable Long companionId) {
|
||||||
|
if (companionId == null) {
|
||||||
|
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
|
||||||
|
}
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
AiCompanionVO result = aiCompanionService.getCompanionById(userId, companionId);
|
||||||
|
return ResultUtils.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/report")
|
||||||
|
@Operation(summary = "举报AI角色", description = "举报AI角色,支持多种举报类型(可多选):1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他")
|
||||||
|
public BaseResponse<Long> reportCompanion(@RequestBody CompanionReportReq req) {
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
Long reportId = aiCompanionReportService.reportCompanion(userId, req);
|
||||||
|
return ResultUtils.success(reportId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,8 +49,7 @@ public class CharacterController {
|
|||||||
@GetMapping("/detail")
|
@GetMapping("/detail")
|
||||||
@Operation(summary = "人设详情", description = "人设详情接口")
|
@Operation(summary = "人设详情", description = "人设详情接口")
|
||||||
public BaseResponse<KeyboardCharacterRespVO> detail(@RequestParam("id") Long id) {
|
public BaseResponse<KeyboardCharacterRespVO> detail(@RequestParam("id") Long id) {
|
||||||
KeyboardCharacter character = characterService.getById(id);
|
return ResultUtils.success(characterService.getDetailById(id));
|
||||||
return ResultUtils.success(BeanUtil.copyProperties(character, KeyboardCharacterRespVO.class));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/listByTag")
|
@GetMapping("/listByTag")
|
||||||
|
|||||||
@@ -3,15 +3,26 @@ package com.yolo.keyborad.controller;
|
|||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
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.ErrorCode;
|
||||||
import com.yolo.keyborad.common.ResultUtils;
|
import com.yolo.keyborad.common.ResultUtils;
|
||||||
import com.yolo.keyborad.exception.BusinessException;
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
import com.yolo.keyborad.mapper.QdrantPayloadMapper;
|
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.ChatReq;
|
||||||
import com.yolo.keyborad.model.dto.chat.ChatSaveReq;
|
import com.yolo.keyborad.model.dto.chat.ChatSaveReq;
|
||||||
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
|
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
|
||||||
|
import com.yolo.keyborad.model.dto.chat.SessionResetReq;
|
||||||
|
import com.yolo.keyborad.model.vo.AudioTaskVO;
|
||||||
|
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
|
||||||
|
import com.yolo.keyborad.model.vo.ChatMessageVO;
|
||||||
|
import com.yolo.keyborad.model.vo.ChatSessionVO;
|
||||||
|
import com.yolo.keyborad.model.vo.ChatVoiceVO;
|
||||||
import com.yolo.keyborad.service.ChatService;
|
import com.yolo.keyborad.service.ChatService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiChatMessageService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiChatSessionService;
|
||||||
import com.yolo.keyborad.service.impl.QdrantVectorService;
|
import com.yolo.keyborad.service.impl.QdrantVectorService;
|
||||||
import io.qdrant.client.grpc.JsonWithInt;
|
import io.qdrant.client.grpc.JsonWithInt;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -44,6 +55,40 @@ public class ChatController {
|
|||||||
@Resource
|
@Resource
|
||||||
private ChatService chatService;
|
private ChatService chatService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiChatMessageService aiChatMessageService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiChatSessionService aiChatSessionService;
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
@PostMapping("/talk")
|
||||||
@Operation(summary = "聊天润色接口", description = "聊天润色接口")
|
@Operation(summary = "聊天润色接口", description = "聊天润色接口")
|
||||||
@@ -79,4 +124,37 @@ public class ChatController {
|
|||||||
log.info("聊天嵌入保存成功,用户ID: {}, 文本长度: {}", chatSaveReq.getUserId(), chatSaveReq.getUserText().length());
|
log.info("聊天嵌入保存成功,用户ID: {}, 文本长度: {}", chatSaveReq.getUserId(), chatSaveReq.getUserText().length());
|
||||||
return ResultUtils.success(true);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/session/reset")
|
||||||
|
@Operation(summary = "重置会话", description = "重置与AI角色的聊天会话,将当前会话设为不活跃并创建新会话,后续聊天记录将绑定到新会话")
|
||||||
|
public BaseResponse<ChatSessionVO> resetSession(@RequestBody SessionResetReq req) {
|
||||||
|
if (req.getCompanionId() == null) {
|
||||||
|
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
var newSession = aiChatSessionService.resetSession(userId, req.getCompanionId());
|
||||||
|
|
||||||
|
ChatSessionVO vo = ChatSessionVO.builder()
|
||||||
|
.sessionId(newSession.getId())
|
||||||
|
.companionId(newSession.getCompanionId())
|
||||||
|
.resetVersion(newSession.getResetVersion())
|
||||||
|
.createdAt(newSession.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResultUtils.success(vo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ public class FileController {
|
|||||||
|
|
||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
@Operation(summary = "上传文件", description = "上传文件接口")
|
@Operation(summary = "上传文件", description = "上传文件接口")
|
||||||
@Parameter(name = "file",required = true,description = "上传的文件")
|
public BaseResponse<String> upload(@RequestPart("file") MultipartFile file){
|
||||||
public BaseResponse<String> upload(@RequestParam("file") MultipartFile file){
|
|
||||||
String fileUrl = fileService.upload(file);
|
String fileUrl = fileService.upload(file);
|
||||||
return ResultUtils.success(fileUrl);
|
return ResultUtils.success(fileUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,13 @@ import com.yolo.keyborad.model.dto.AppleLoginReq;
|
|||||||
import com.yolo.keyborad.model.dto.user.*;
|
import com.yolo.keyborad.model.dto.user.*;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardFeedback;
|
import com.yolo.keyborad.model.entity.KeyboardFeedback;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserInviteCodes;
|
||||||
|
import com.yolo.keyborad.model.vo.user.InviteCodeRespVO;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserInfoRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserInfoRespVO;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
||||||
import com.yolo.keyborad.service.IAppleService;
|
import com.yolo.keyborad.service.IAppleService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardFeedbackService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
|
||||||
import com.yolo.keyborad.service.UserService;
|
import com.yolo.keyborad.service.UserService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
@@ -41,8 +45,10 @@ public class UserController {
|
|||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private com.yolo.keyborad.service.KeyboardFeedbackService feedbackService;
|
private KeyboardFeedbackService feedbackService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardUserInviteCodesService inviteCodesService;
|
||||||
/**
|
/**
|
||||||
* 苹果登录
|
* 苹果登录
|
||||||
*
|
*
|
||||||
@@ -133,4 +139,10 @@ public class UserController {
|
|||||||
return ResultUtils.success(userService.bindInviteCode(bindInviteCodeDTO));
|
return ResultUtils.success(userService.bindInviteCode(bindInviteCodeDTO));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/inviteCode")
|
||||||
|
@Operation(summary = "查询邀请码", description = "查询用户自己的邀请码")
|
||||||
|
public BaseResponse<InviteCodeRespVO> getInviteCode() {
|
||||||
|
long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
return ResultUtils.success( inviteCodesService.getUserInviteCode(userId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局异常处理器
|
* 全局异常处理器
|
||||||
@@ -25,6 +26,20 @@ public class GlobalExceptionHandler {
|
|||||||
this.i18nService = i18nService;
|
this.i18nService = i18nService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MissingServletRequestPartException.class)
|
||||||
|
public BaseResponse<?> missingServletRequestPartExceptionHandler(MissingServletRequestPartException e, HttpServletRequest request) {
|
||||||
|
log.error("missingServletRequestPartException: " + e.getMessage(), e);
|
||||||
|
|
||||||
|
String acceptLanguage = request.getHeader("Accept-Language");
|
||||||
|
String errorMessage = i18nService.getMessageWithAcceptLanguage(String.valueOf(ErrorCode.FILE_IS_EMPTY.getCode()), acceptLanguage);
|
||||||
|
|
||||||
|
if (errorMessage == null) {
|
||||||
|
errorMessage = ErrorCode.FILE_IS_EMPTY.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResultUtils.error(ErrorCode.FILE_IS_EMPTY.getCode(), errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(BusinessException.class)
|
@ExceptionHandler(BusinessException.class)
|
||||||
public BaseResponse<?> businessExceptionHandler(BusinessException e, HttpServletRequest request) {
|
public BaseResponse<?> businessExceptionHandler(BusinessException e, HttpServletRequest request) {
|
||||||
log.error("businessException: " + e.getMessage(), e);
|
log.error("businessException: " + e.getMessage(), e);
|
||||||
|
|||||||
@@ -18,15 +18,27 @@ public class RequestBodyCacheFilter extends OncePerRequestFilter {
|
|||||||
FilterChain filterChain)
|
FilterChain filterChain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
|
|
||||||
// 只缓存一次
|
// 获取请求的内容类型
|
||||||
if (!(request instanceof CachedBodyHttpServletRequest)) {
|
String contentType = request.getContentType();
|
||||||
|
|
||||||
|
// 跳过 multipart 请求,避免破坏文件上传功能
|
||||||
|
if (contentType != null && contentType.toLowerCase().startsWith("multipart/")) {
|
||||||
|
// 对于文件上传请求,直接放行,不进行请求体缓存
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已经进行过请求体缓存,避免重复缓存
|
||||||
|
if (!(request instanceof CachedBodyHttpServletRequest)) {
|
||||||
|
// 创建缓存请求对象,包装原始请求以支持多次读取请求体
|
||||||
CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request);
|
CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request);
|
||||||
|
|
||||||
|
// 使用缓存的请求对象继续执行过滤器链
|
||||||
filterChain.doFilter(cachedRequest, response);
|
filterChain.doFilter(cachedRequest, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果已经是缓存过的请求,则直接执行过滤器链
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.yolo.keyborad.listener;
|
||||||
|
|
||||||
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardThemeStyles;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardThemes;
|
||||||
|
import com.yolo.keyborad.model.vo.themes.KeyboardThemeStylesRespVO;
|
||||||
|
import com.yolo.keyborad.model.vo.themes.KeyboardThemesRespVO;
|
||||||
|
import com.yolo.keyborad.service.KeyboardThemeStylesService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardThemesService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题缓存初始化器
|
||||||
|
* 在应用启动时按风格将主题缓存到Redis
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class ThemeCacheInitializer implements ApplicationRunner {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题按风格分组的缓存key前缀
|
||||||
|
*/
|
||||||
|
private static final String THEME_STYLE_KEY = "theme:style:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所有风格列表的缓存key
|
||||||
|
*/
|
||||||
|
private static final String THEME_STYLES_KEY = "theme:styles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所有主题列表的缓存key(风格ID=9999表示全部)
|
||||||
|
*/
|
||||||
|
private static final String THEME_ALL_KEY = "theme:style:all";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存过期时间(天)
|
||||||
|
*/
|
||||||
|
private static final long CACHE_EXPIRE_DAYS = 1;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardThemesService themesService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardThemeStylesService themeStylesService;
|
||||||
|
|
||||||
|
@Resource(name = "objectRedisTemplate")
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
try {
|
||||||
|
log.info("开始缓存主题数据到Redis...");
|
||||||
|
|
||||||
|
// 1. 缓存所有风格列表
|
||||||
|
cacheAllStyles();
|
||||||
|
|
||||||
|
// 2. 按风格分组缓存主题
|
||||||
|
cacheThemesByStyle();
|
||||||
|
|
||||||
|
log.info("主题数据缓存完成");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("缓存主题数据失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存所有风格列表
|
||||||
|
*/
|
||||||
|
private void cacheAllStyles() {
|
||||||
|
List<KeyboardThemeStyles> stylesList = themeStylesService.lambdaQuery()
|
||||||
|
.eq(KeyboardThemeStyles::getDeleted, false)
|
||||||
|
.list();
|
||||||
|
|
||||||
|
List<KeyboardThemeStylesRespVO> stylesVOList = BeanUtil.copyToList(stylesList, KeyboardThemeStylesRespVO.class);
|
||||||
|
redisTemplate.opsForValue().set(THEME_STYLES_KEY, stylesVOList, CACHE_EXPIRE_DAYS, TimeUnit.DAYS);
|
||||||
|
log.info("已缓存 {} 种主题风格", stylesVOList.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按风格分组缓存主题
|
||||||
|
*/
|
||||||
|
private void cacheThemesByStyle() {
|
||||||
|
// 查询所有有效主题
|
||||||
|
List<KeyboardThemes> allThemes = themesService.lambdaQuery()
|
||||||
|
.eq(KeyboardThemes::getDeleted, false)
|
||||||
|
.eq(KeyboardThemes::getThemeStatus, true)
|
||||||
|
.orderByAsc(KeyboardThemes::getSort)
|
||||||
|
.list();
|
||||||
|
|
||||||
|
// 转换为VO(不设置购买状态,缓存的是公共数据)
|
||||||
|
List<KeyboardThemesRespVO> allThemesVO = allThemes.stream()
|
||||||
|
.map(theme -> BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 缓存所有主题(风格ID=all)
|
||||||
|
redisTemplate.opsForValue().set(THEME_ALL_KEY, allThemesVO, CACHE_EXPIRE_DAYS, TimeUnit.DAYS);
|
||||||
|
log.info("已缓存所有主题,共 {} 个", allThemesVO.size());
|
||||||
|
|
||||||
|
// 按风格分组
|
||||||
|
Map<Long, List<KeyboardThemesRespVO>> themesByStyle = allThemesVO.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardThemesRespVO::getThemeStyle));
|
||||||
|
|
||||||
|
// 按风格缓存主题
|
||||||
|
for (Map.Entry<Long, List<KeyboardThemesRespVO>> entry : themesByStyle.entrySet()) {
|
||||||
|
Long styleId = entry.getKey();
|
||||||
|
List<KeyboardThemesRespVO> themes = entry.getValue();
|
||||||
|
String key = THEME_STYLE_KEY + styleId;
|
||||||
|
redisTemplate.opsForValue().set(key, themes, CACHE_EXPIRE_DAYS, TimeUnit.DAYS);
|
||||||
|
log.info("已缓存风格ID={} 的主题,共 {} 个", styleId, themes.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动刷新缓存(可通过接口调用)
|
||||||
|
*/
|
||||||
|
public void refreshCache() {
|
||||||
|
log.info("手动刷新主题缓存...");
|
||||||
|
clearCache();
|
||||||
|
cacheAllStyles();
|
||||||
|
cacheThemesByStyle();
|
||||||
|
log.info("主题缓存刷新完成");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除主题相关缓存
|
||||||
|
*/
|
||||||
|
public void clearCache() {
|
||||||
|
// 删除风格列表缓存
|
||||||
|
redisTemplate.delete(THEME_STYLES_KEY);
|
||||||
|
redisTemplate.delete(THEME_ALL_KEY);
|
||||||
|
|
||||||
|
// 删除所有风格下的主题缓存
|
||||||
|
var keys = redisTemplate.keys(THEME_STYLE_KEY + "*");
|
||||||
|
if (keys != null && !keys.isEmpty()) {
|
||||||
|
redisTemplate.delete(keys);
|
||||||
|
}
|
||||||
|
log.info("已清除主题相关缓存");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/28 16:20
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface KeyboardAiChatSessionMapper extends BaseMapper<KeyboardAiChatSession> {
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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,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> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanionReport;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/29 16:17
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface KeyboardAiCompanionReportMapper extends BaseMapper<KeyboardAiCompanionReport> {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.yolo.keyborad.model.dto.chat;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/28
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "会话重置请求")
|
||||||
|
public class SessionResetReq {
|
||||||
|
|
||||||
|
@Schema(description = "AI陪聊角色ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private Long companionId;
|
||||||
|
}
|
||||||
@@ -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 = "父评论ID,NULL表示一级评论")
|
||||||
|
private Long parentId;
|
||||||
|
|
||||||
|
@Schema(description = "根评论ID,用于标识同一评论线程")
|
||||||
|
private Long rootId;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,30 @@
|
|||||||
|
package com.yolo.keyborad.model.dto.companion;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/29
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "AI角色举报请求")
|
||||||
|
public class CompanionReportReq {
|
||||||
|
|
||||||
|
@Schema(description = "AI角色ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private Long companionId;
|
||||||
|
|
||||||
|
@Schema(description = "举报类型列表:1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他,支持多选", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private List<Short> reportTypes;
|
||||||
|
|
||||||
|
@Schema(description = "详细描述")
|
||||||
|
private String reportDesc;
|
||||||
|
|
||||||
|
@Schema(description = "聊天上下文快照JSON")
|
||||||
|
private String chatContext;
|
||||||
|
|
||||||
|
@Schema(description = "图片证据URL")
|
||||||
|
private String evidenceImageUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
@TableField(value = "session_id")
|
||||||
|
@Schema(description = "会话Id")
|
||||||
|
private Long sessionId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.yolo.keyborad.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.util.Date;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/28 16:20
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户与AI陪伴角色的聊天会话表,用于支持聊天重置与关系重启
|
||||||
|
*/
|
||||||
|
@Schema(description="用户与AI陪伴角色的聊天会话表,用于支持聊天重置与关系重启")
|
||||||
|
@Data
|
||||||
|
@TableName(value = "keyboard_ai_chat_session")
|
||||||
|
public class KeyboardAiChatSession {
|
||||||
|
/**
|
||||||
|
* 聊天会话唯一ID
|
||||||
|
*/
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
@Schema(description="聊天会话唯一ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
@TableField(value = "user_id")
|
||||||
|
@Schema(description="用户ID")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 陪伴角色ID
|
||||||
|
*/
|
||||||
|
@TableField(value = "companion_id")
|
||||||
|
@Schema(description="陪伴角色ID")
|
||||||
|
private Long companionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话重置版本号,用于标识第几次重新开始陪伴关系
|
||||||
|
*/
|
||||||
|
@TableField(value = "reset_version")
|
||||||
|
@Schema(description="会话重置版本号,用于标识第几次重新开始陪伴关系")
|
||||||
|
private Integer resetVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为当前活跃会话(true=当前使用中)
|
||||||
|
*/
|
||||||
|
@TableField(value = "is_active")
|
||||||
|
@Schema(description="是否为当前活跃会话(true=当前使用中)")
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话创建时间
|
||||||
|
*/
|
||||||
|
@TableField(value = "created_at")
|
||||||
|
@Schema(description="会话创建时间")
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话结束时间(用户重置或系统关闭会话时记录)
|
||||||
|
*/
|
||||||
|
@TableField(value = "ended_at")
|
||||||
|
@Schema(description="会话结束时间(用户重置或系统关闭会话时记录)")
|
||||||
|
private Date endedAt;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父评论ID,NULL表示一级评论
|
||||||
|
*/
|
||||||
|
@TableField(value = "parent_id")
|
||||||
|
@Schema(description="父评论ID,NULL表示一级评论")
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.yolo.keyborad.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.util.Date;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/29 16:17
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI角色举报记录表
|
||||||
|
*/
|
||||||
|
@Schema(description="AI角色举报记录表")
|
||||||
|
@Data
|
||||||
|
@TableName(value = "keyboard_ai_companion_report")
|
||||||
|
public class KeyboardAiCompanionReport {
|
||||||
|
/**
|
||||||
|
* 举报记录唯一ID
|
||||||
|
*/
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
@Schema(description="举报记录唯一ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 被举报的AI角色ID(逻辑关联 keyboard_ai_companion.id,无物理外键)
|
||||||
|
*/
|
||||||
|
@TableField(value = "companion_id")
|
||||||
|
@Schema(description="被举报的AI角色ID(逻辑关联 keyboard_ai_companion.id,无物理外键)")
|
||||||
|
private Long companionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起举报的用户ID(逻辑关联用户表)
|
||||||
|
*/
|
||||||
|
@TableField(value = "user_id")
|
||||||
|
@Schema(description="发起举报的用户ID(逻辑关联用户表)")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 举报类型:1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他,多选时逗号分隔
|
||||||
|
*/
|
||||||
|
@TableField(value = "report_type")
|
||||||
|
@Schema(description="举报类型:1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他,多选时逗号分隔")
|
||||||
|
private String reportType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户填写的详细举报描述
|
||||||
|
*/
|
||||||
|
@TableField(value = "report_desc")
|
||||||
|
@Schema(description="用户填写的详细举报描述")
|
||||||
|
private String reportDesc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 违规现场:举报时的聊天上下文快照(建议存JSON字符串),用于审核取证
|
||||||
|
*/
|
||||||
|
@TableField(value = "chat_context")
|
||||||
|
@Schema(description="违规现场:举报时的聊天上下文快照(建议存JSON字符串),用于审核取证")
|
||||||
|
private String chatContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片证据:用户上传的截图URL
|
||||||
|
*/
|
||||||
|
@TableField(value = "evidence_image_url")
|
||||||
|
@Schema(description="图片证据:用户上传的截图URL")
|
||||||
|
private String evidenceImageUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理状态:0=待处理, 1=违规确立(已处罚), 2=无效举报/已驳回, 3=已忽略
|
||||||
|
*/
|
||||||
|
@TableField(value = "\"status\"")
|
||||||
|
@Schema(description="处理状态:0=待处理, 1=违规确立(已处罚), 2=无效举报/已驳回, 3=已忽略")
|
||||||
|
private Short status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员处理备注(记录处理理由或处罚措施)
|
||||||
|
*/
|
||||||
|
@TableField(value = "admin_remark")
|
||||||
|
@Schema(description="管理员处理备注(记录处理理由或处罚措施)")
|
||||||
|
private String adminRemark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 举报提交时间
|
||||||
|
*/
|
||||||
|
@TableField(value = "created_at")
|
||||||
|
@Schema(description="举报提交时间")
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最后更新时间
|
||||||
|
*/
|
||||||
|
@TableField(value = "updated_at")
|
||||||
|
@Schema(description="最后更新时间")
|
||||||
|
private Date updatedAt;
|
||||||
|
}
|
||||||
@@ -121,4 +121,8 @@ public class KeyboardUser {
|
|||||||
@TableField(value = "vip_expiry")
|
@TableField(value = "vip_expiry")
|
||||||
@Schema(description = "VIP 过期时间")
|
@Schema(description = "VIP 过期时间")
|
||||||
private Date vipExpiry;
|
private Date vipExpiry;
|
||||||
|
|
||||||
|
@TableField(value = "vip_level")
|
||||||
|
@Schema(description = "vip等级")
|
||||||
|
private Integer vipLevel;
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package com.yolo.keyborad.model.entity;
|
package com.yolo.keyborad.model.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
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 io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -18,6 +15,7 @@ import lombok.Data;
|
|||||||
*/
|
*/
|
||||||
@Schema(description="用户生成的邀请码表,用于邀请新用户注册/安装并建立邀请关系")
|
@Schema(description="用户生成的邀请码表,用于邀请新用户注册/安装并建立邀请关系")
|
||||||
@Data
|
@Data
|
||||||
|
@KeySequence("invite_codes_id_seq")
|
||||||
@TableName(value = "keyboard_user_invite_codes")
|
@TableName(value = "keyboard_user_invite_codes")
|
||||||
public class KeyboardUserInviteCodes {
|
public class KeyboardUserInviteCodes {
|
||||||
/**
|
/**
|
||||||
@@ -75,4 +73,25 @@ public class KeyboardUserInviteCodes {
|
|||||||
@TableField(value = "used_count")
|
@TableField(value = "used_count")
|
||||||
@Schema(description="邀请码已使用次数")
|
@Schema(description="邀请码已使用次数")
|
||||||
private Integer usedCount;
|
private Integer usedCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请码类型:USER=普通用户邀请码,TENANT=租户邀请码
|
||||||
|
*/
|
||||||
|
@TableField(value = "invite_type")
|
||||||
|
@Schema(description="邀请码类型:USER=普通用户邀请码,AGENT=租户邀请码")
|
||||||
|
private String inviteType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请码所属租户ID(当inviteType=AGENT时使用)
|
||||||
|
*/
|
||||||
|
@TableField(value = "owner_tenant_id")
|
||||||
|
@Schema(description="邀请码所属租户ID(当inviteType=AGENT时使用)")
|
||||||
|
private Long ownerTenantId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请码所属租户用户ID(当inviteType=AGENT时使用)
|
||||||
|
*/
|
||||||
|
@TableField(value = "owner_system_user_id")
|
||||||
|
@Schema(description="邀请码所属租户用户ID(当inviteType=AGENT时使用)")
|
||||||
|
private Long ownerSystemUserId;
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ import lombok.Data;
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
* @date: 2025/12/19 13:26
|
* @date: 2025/12/29 13:58
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,4 +82,39 @@ public class KeyboardUserInvites {
|
|||||||
@TableField(value = "bind_user_agent")
|
@TableField(value = "bind_user_agent")
|
||||||
@Schema(description = "userAgent")
|
@Schema(description = "userAgent")
|
||||||
private String bindUserAgent;
|
private String bindUserAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请码类型快照:USER=普通用户邀请,AGENT=代理邀请
|
||||||
|
*/
|
||||||
|
@TableField(value = "invite_type")
|
||||||
|
@Schema(description = "邀请码类型快照:USER=普通用户邀请,AGENT=代理邀请")
|
||||||
|
private String inviteType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收益结算归属租户ID(代理结算用,绑定时固化)
|
||||||
|
*/
|
||||||
|
@TableField(value = "profit_tenant_id")
|
||||||
|
@Schema(description = "收益结算归属租户ID(代理结算用,绑定时固化)")
|
||||||
|
private Long profitTenantId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收益归因员工ID(用于区分租户员工/渠道,绑定时固化)
|
||||||
|
*/
|
||||||
|
@TableField(value = "profit_employee_id")
|
||||||
|
@Schema(description = "收益归因员工ID(用于区分租户员工/渠道,绑定时固化)")
|
||||||
|
private Long profitEmployeeId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请人所属租户ID快照(便于审计/对账,可选)
|
||||||
|
*/
|
||||||
|
@TableField(value = "inviter_tenant_id")
|
||||||
|
@Schema(description = "邀请人所属租户ID快照(便于审计/对账,可选)")
|
||||||
|
private Long inviterTenantId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请码字符串快照(便于排查,可选)
|
||||||
|
*/
|
||||||
|
@TableField(value = "invite_code")
|
||||||
|
@Schema(description = "邀请码字符串快照(便于排查,可选)")
|
||||||
|
private String inviteCode;
|
||||||
}
|
}
|
||||||
69
src/main/java/com/yolo/keyborad/model/vo/AiCompanionVO.java
Normal file
69
src/main/java/com/yolo/keyborad/model/vo/AiCompanionVO.java
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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 Integer likeCount;
|
||||||
|
|
||||||
|
@Schema(description = "评论总数")
|
||||||
|
private Integer commentCount;
|
||||||
|
|
||||||
|
@Schema(description = "当前用户是否已点赞")
|
||||||
|
private Boolean liked;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间")
|
||||||
|
private Date createdAt;
|
||||||
|
}
|
||||||
37
src/main/java/com/yolo/keyborad/model/vo/AudioTaskVO.java
Normal file
37
src/main/java/com/yolo/keyborad/model/vo/AudioTaskVO.java
Normal 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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
29
src/main/java/com/yolo/keyborad/model/vo/ChatMessageVO.java
Normal file
29
src/main/java/com/yolo/keyborad/model/vo/ChatMessageVO.java
Normal 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;
|
||||||
|
}
|
||||||
29
src/main/java/com/yolo/keyborad/model/vo/ChatSessionVO.java
Normal file
29
src/main/java/com/yolo/keyborad/model/vo/ChatSessionVO.java
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package com.yolo.keyborad.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/28
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "会话信息VO")
|
||||||
|
public class ChatSessionVO {
|
||||||
|
|
||||||
|
@Schema(description = "会话ID")
|
||||||
|
private Long sessionId;
|
||||||
|
|
||||||
|
@Schema(description = "AI陪聊角色ID")
|
||||||
|
private Long companionId;
|
||||||
|
|
||||||
|
@Schema(description = "会话版本号")
|
||||||
|
private Integer resetVersion;
|
||||||
|
|
||||||
|
@Schema(description = "会话创建时间")
|
||||||
|
private Date createdAt;
|
||||||
|
}
|
||||||
32
src/main/java/com/yolo/keyborad/model/vo/ChatVoiceVO.java
Normal file
32
src/main/java/com/yolo/keyborad/model/vo/ChatVoiceVO.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语音对话响应
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
55
src/main/java/com/yolo/keyborad/model/vo/CommentVO.java
Normal file
55
src/main/java/com/yolo/keyborad/model/vo/CommentVO.java
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package com.yolo.keyborad.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
@Schema(description = "回复列表(仅一级评论有值,默认返回前3条)")
|
||||||
|
private List<CommentVO> replies;
|
||||||
|
|
||||||
|
@Schema(description = "回复总数")
|
||||||
|
private Integer replyCount;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
26
src/main/java/com/yolo/keyborad/model/vo/TextToSpeechVO.java
Normal file
26
src/main/java/com/yolo/keyborad/model/vo/TextToSpeechVO.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.yolo.keyborad.model.vo.user;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请码响应VO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "邀请码信息")
|
||||||
|
public class InviteCodeRespVO {
|
||||||
|
|
||||||
|
@Schema(description = "邀请码")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Schema(description = "邀请码状态:1=启用,0=停用")
|
||||||
|
private Short status;
|
||||||
|
|
||||||
|
@Schema(description = "已使用次数")
|
||||||
|
private Integer usedCount;
|
||||||
|
|
||||||
|
@Schema(description = "最大可使用次数")
|
||||||
|
private Integer maxUses;
|
||||||
|
|
||||||
|
@Schema(description = "过期时间")
|
||||||
|
private Date expiresAt;
|
||||||
|
|
||||||
|
@Schema(description = "H5链接")
|
||||||
|
private String h5Link;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.yolo.keyborad.model.vo.user;
|
package com.yolo.keyborad.model.vo.user;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@@ -52,4 +53,8 @@ public class KeyboardUserInfoRespVO {
|
|||||||
@Schema(description = "VIP 过期时间")
|
@Schema(description = "VIP 过期时间")
|
||||||
private String vipExpiry;
|
private String vipExpiry;
|
||||||
|
|
||||||
|
|
||||||
|
@Schema(description = "vip等级")
|
||||||
|
private Integer vipLevel;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -55,4 +55,8 @@ public class KeyboardUserRespVO {
|
|||||||
*/
|
*/
|
||||||
@Schema(description = "VIP 过期时间")
|
@Schema(description = "VIP 过期时间")
|
||||||
private Date vipExpiry;
|
private Date vipExpiry;
|
||||||
|
|
||||||
|
@TableField(value = "vip_level")
|
||||||
|
@Schema(description = "vip等级")
|
||||||
|
private Integer vipLevel;
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,9 @@ package com.yolo.keyborad.service;
|
|||||||
|
|
||||||
import com.yolo.keyborad.model.dto.chat.ChatReq;
|
import com.yolo.keyborad.model.dto.chat.ChatReq;
|
||||||
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
|
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 org.springframework.http.codec.ServerSentEvent;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
@@ -11,4 +14,23 @@ import reactor.core.publisher.Flux;
|
|||||||
*/
|
*/
|
||||||
public interface ChatService {
|
public interface ChatService {
|
||||||
Flux<ServerSentEvent<ChatStreamMessage>> talk(ChatReq chatReq);
|
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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
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,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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户聊过天的所有AI角色ID列表
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 聊过天的AI角色ID列表(按最近聊天时间倒序)
|
||||||
|
*/
|
||||||
|
List<Long> getChattedCompanionIds(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/28 16:20
|
||||||
|
*/
|
||||||
|
public interface KeyboardAiChatSessionService extends IService<KeyboardAiChatSession> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户与AI角色的活跃会话,如果不存在则创建新会话
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param companionId AI角色ID
|
||||||
|
* @return 活跃会话
|
||||||
|
*/
|
||||||
|
KeyboardAiChatSession getOrCreateActiveSession(Long userId, Long companionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户与AI角色的活跃会话ID,如果不存在则创建新会话
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param companionId AI角色ID
|
||||||
|
* @return 活跃会话ID
|
||||||
|
*/
|
||||||
|
Long getOrCreateActiveSessionId(Long userId, Long companionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置会话:将当前活跃会话设为不活跃,并创建新的活跃会话
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param companionId AI角色ID
|
||||||
|
* @return 新创建的活跃会话
|
||||||
|
*/
|
||||||
|
KeyboardAiChatSession resetSession(Long userId, Long companionId);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanionLike;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/27 18:18
|
||||||
|
*/
|
||||||
|
public interface KeyboardAiCompanionLikeService extends IService<KeyboardAiCompanionLike> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞/取消点赞AI角色
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param companionId AI角色ID
|
||||||
|
* @return true=点赞成功,false=取消点赞成功
|
||||||
|
*/
|
||||||
|
boolean toggleLike(Long userId, Long companionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否已点赞某AI角色
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param companionId AI角色ID
|
||||||
|
* @return true=已点赞,false=未点赞
|
||||||
|
*/
|
||||||
|
boolean hasLiked(Long userId, Long companionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取用户已点赞的AI角色ID列表
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param companionIds AI角色ID列表
|
||||||
|
* @return 已点赞的AI角色ID集合
|
||||||
|
*/
|
||||||
|
Set<Long> getLikedCompanionIds(Long userId, List<Long> companionIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户点赞过的所有AI角色ID列表
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 已点赞的AI角色ID列表
|
||||||
|
*/
|
||||||
|
List<Long> getAllLikedCompanionIds(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.dto.companion.CompanionReportReq;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanionReport;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/29 16:17
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface KeyboardAiCompanionReportService extends IService<KeyboardAiCompanionReport>{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 举报AI角色
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param req 举报请求
|
||||||
|
* @return 举报记录ID
|
||||||
|
*/
|
||||||
|
Long reportCompanion(Long userId, CompanionReportReq req);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @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陪聊角色(带当前用户点赞状态)
|
||||||
|
*
|
||||||
|
* @param userId 当前用户ID
|
||||||
|
* @param pageNum 页码
|
||||||
|
* @param pageSize 每页数量
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
IPage<AiCompanionVO> pageListWithLikeStatus(Long userId, Integer pageNum, Integer pageSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据AI人设ID获取系统提示词
|
||||||
|
*
|
||||||
|
* @param companionId AI人设ID
|
||||||
|
* @return 系统提示词
|
||||||
|
*/
|
||||||
|
String getSystemPromptById(Long companionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户点赞过的AI角色列表
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 点赞过的AI角色列表
|
||||||
|
*/
|
||||||
|
List<AiCompanionVO> getLikedCompanions(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户聊过天的AI角色列表
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 聊过天的AI角色列表
|
||||||
|
*/
|
||||||
|
List<AiCompanionVO> getChattedCompanions(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取AI角色详情(带点赞数、评论数和当前用户点赞状态)
|
||||||
|
*
|
||||||
|
* @param userId 当前用户ID
|
||||||
|
* @param companionId AI角色ID
|
||||||
|
* @return AI角色详情
|
||||||
|
*/
|
||||||
|
AiCompanionVO getCompanionById(Long userId, Long companionId);
|
||||||
|
}
|
||||||
@@ -35,4 +35,6 @@ public interface KeyboardCharacterService extends IService<KeyboardCharacter>{
|
|||||||
List<KeyboardCharacterRespVO> selectListByTagWithNotLogin(Long tagId);
|
List<KeyboardCharacterRespVO> selectListByTagWithNotLogin(Long tagId);
|
||||||
|
|
||||||
void addDefaultUserCharacter(Long userId);
|
void addDefaultUserCharacter(Long userId);
|
||||||
|
|
||||||
|
KeyboardCharacterRespVO getDetailById(Long id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.yolo.keyborad.service;
|
|||||||
|
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUserInviteCodes;
|
import com.yolo.keyborad.model.entity.KeyboardUserInviteCodes;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.yolo.keyborad.model.vo.user.InviteCodeRespVO;
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
* @date: 2025/12/18 16:26
|
* @date: 2025/12/18 16:26
|
||||||
@@ -15,4 +16,18 @@ public interface KeyboardUserInviteCodesService extends IService<KeyboardUserInv
|
|||||||
*/
|
*/
|
||||||
KeyboardUserInviteCodes validateInviteCode(String code);
|
KeyboardUserInviteCodes validateInviteCode(String code);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的邀请码
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 邀请码实体
|
||||||
|
*/
|
||||||
|
InviteCodeRespVO getUserInviteCode(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为用户创建邀请码
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 创建的邀请码实体
|
||||||
|
*/
|
||||||
|
KeyboardUserInviteCodes createInviteCode(Long userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,25 +10,47 @@ import com.yolo.keyborad.config.NacosAppConfigCenter;
|
|||||||
import com.yolo.keyborad.exception.BusinessException;
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
import com.yolo.keyborad.model.dto.chat.ChatReq;
|
import com.yolo.keyborad.model.dto.chat.ChatReq;
|
||||||
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
|
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.KeyboardCharacter;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
|
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
|
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 com.yolo.keyborad.service.*;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.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.ai.openai.OpenAiChatOptions;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.http.codec.ServerSentEvent;
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
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.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
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.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
@@ -61,6 +83,29 @@ public class ChatServiceImpl implements ChatService {
|
|||||||
@Resource
|
@Resource
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionService aiCompanionService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiChatMessageService aiChatMessageService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiChatSessionService aiChatSessionService;
|
||||||
|
|
||||||
|
@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;
|
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||||
|
|
||||||
public ChatServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
public ChatServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
||||||
@@ -321,4 +366,241 @@ public class ChatServiceImpl implements ChatService {
|
|||||||
.build()
|
.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 的用户需要扣减)
|
||||||
|
boolean needDeductQuota = vipLevel <= 1;
|
||||||
|
String redisKey = CHAT_DAILY_LIMIT_PREFIX + userId;
|
||||||
|
Integer dailyLimit = appConfig.getUserRegisterProperties().getVipFreeTrialTalk();
|
||||||
|
|
||||||
|
// 如果VIP等级 <= 1,先检查每日体验次数是否用完
|
||||||
|
if (needDeductQuota) {
|
||||||
|
// 获取当前使用次数
|
||||||
|
String countStr = stringRedisTemplate.opsForValue().get(redisKey);
|
||||||
|
int currentCount = countStr != null ? Integer.parseInt(countStr) : 0;
|
||||||
|
|
||||||
|
// 检查是否超出限制,超出直接返回
|
||||||
|
if (currentCount >= dailyLimit) {
|
||||||
|
log.warn("用户 {} VIP等级 {} 已达到每日体验次数限制 {}", userId, vipLevel, dailyLimit);
|
||||||
|
throw new BusinessException(ErrorCode.VIP_TRIAL_LIMIT_REACHED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long 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);
|
||||||
|
|
||||||
|
// ============ 成功后扣减体验次数 ============
|
||||||
|
if (needDeductQuota) {
|
||||||
|
Long newCount = stringRedisTemplate.opsForValue().increment(redisKey);
|
||||||
|
|
||||||
|
// 设置到午夜过期(只有第一次设置时需要设置过期时间)
|
||||||
|
if (newCount != null && newCount == 1) {
|
||||||
|
// 计算到今天午夜的剩余秒数
|
||||||
|
LocalDateTime now = LocalDateTime.now(ZoneId.of("America/New_York"));
|
||||||
|
LocalDateTime midnight = LocalDateTime.of(LocalDate.now(ZoneId.of("America/New_York")).plusDays(1), LocalTime.MIDNIGHT);
|
||||||
|
long secondsUntilMidnight = ChronoUnit.SECONDS.between(now, midnight);
|
||||||
|
stringRedisTemplate.expire(redisKey, secondsUntilMidnight, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("用户 {} VIP等级 {} 今日已使用 {}/{} 次", userId, vipLevel, newCount, dailyLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成音频任务 ID
|
||||||
|
String audioId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
|
||||||
|
// 初始化音频任务状态为 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();
|
||||||
|
|
||||||
|
// 获取或创建活跃会话
|
||||||
|
Long sessionId = aiChatSessionService.getOrCreateActiveSessionId(userId, companionId);
|
||||||
|
|
||||||
|
// 保存用户消息 (sender=1)
|
||||||
|
KeyboardAiChatMessage userMessage = new KeyboardAiChatMessage();
|
||||||
|
userMessage.setUserId(userId);
|
||||||
|
userMessage.setCompanionId(companionId);
|
||||||
|
userMessage.setSessionId(sessionId);
|
||||||
|
userMessage.setSender((short) 1);
|
||||||
|
userMessage.setContent(userContent);
|
||||||
|
userMessage.setCreatedAt(now);
|
||||||
|
|
||||||
|
// 保存AI响应 (sender=2)
|
||||||
|
KeyboardAiChatMessage aiMessage = new KeyboardAiChatMessage();
|
||||||
|
aiMessage.setUserId(userId);
|
||||||
|
aiMessage.setCompanionId(companionId);
|
||||||
|
aiMessage.setSessionId(sessionId);
|
||||||
|
aiMessage.setSender((short) 2);
|
||||||
|
aiMessage.setContent(aiResponse);
|
||||||
|
aiMessage.setCreatedAt(now);
|
||||||
|
|
||||||
|
// 批量保存
|
||||||
|
aiChatMessageService.saveBatch(List.of(userMessage, aiMessage));
|
||||||
|
log.info("聊天记录保存成功, userId: {}, companionId: {}, sessionId: {}", userId, companionId, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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.entity.KeyboardAiChatSession;
|
||||||
|
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiChatMessageService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiChatSessionService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/26 17:00
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class KeyboardAiChatMessageServiceImpl extends ServiceImpl<KeyboardAiChatMessageMapper, KeyboardAiChatMessage> implements KeyboardAiChatMessageService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiChatSessionService sessionService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<ChatMessageHistoryVO> pageHistory(Long userId, Long companionId, Integer pageNum, Integer pageSize) {
|
||||||
|
// 获取当前活跃会话
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatSession> sessionWrapper = new LambdaQueryWrapper<>();
|
||||||
|
sessionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
|
||||||
|
.eq(KeyboardAiChatSession::getCompanionId, companionId)
|
||||||
|
.eq(KeyboardAiChatSession::getIsActive, true);
|
||||||
|
KeyboardAiChatSession activeSession = sessionService.getOne(sessionWrapper);
|
||||||
|
|
||||||
|
// 如果没有活跃会话,返回空分页
|
||||||
|
if (activeSession == null) {
|
||||||
|
return new Page<ChatMessageHistoryVO>(pageNum, pageSize).setRecords(Collections.emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<KeyboardAiChatMessage> page = new Page<>(pageNum, pageSize);
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatMessage> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiChatMessage::getUserId, userId)
|
||||||
|
.eq(KeyboardAiChatMessage::getCompanionId, companionId)
|
||||||
|
.eq(KeyboardAiChatMessage::getSessionId, activeSession.getId())
|
||||||
|
.orderByAsc(KeyboardAiChatMessage::getCreatedAt)
|
||||||
|
.orderByAsc(KeyboardAiChatMessage::getId);
|
||||||
|
IPage<KeyboardAiChatMessage> entityPage = this.page(page, queryWrapper);
|
||||||
|
return entityPage.convert(entity -> BeanUtil.copyProperties(entity, ChatMessageHistoryVO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<KeyboardAiChatMessage> getRecentMessages(Long userId, Long companionId, int limit) {
|
||||||
|
// 获取当前活跃会话
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatSession> sessionWrapper = new LambdaQueryWrapper<>();
|
||||||
|
sessionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
|
||||||
|
.eq(KeyboardAiChatSession::getCompanionId, companionId)
|
||||||
|
.eq(KeyboardAiChatSession::getIsActive, true);
|
||||||
|
KeyboardAiChatSession activeSession = sessionService.getOne(sessionWrapper);
|
||||||
|
|
||||||
|
// 如果没有活跃会话,返回空列表
|
||||||
|
if (activeSession == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatMessage> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiChatMessage::getUserId, userId)
|
||||||
|
.eq(KeyboardAiChatMessage::getCompanionId, companionId)
|
||||||
|
.eq(KeyboardAiChatMessage::getSessionId, activeSession.getId())
|
||||||
|
.orderByDesc(KeyboardAiChatMessage::getCreatedAt)
|
||||||
|
.last("LIMIT " + limit);
|
||||||
|
List<KeyboardAiChatMessage> messages = this.list(queryWrapper);
|
||||||
|
// 反转列表,使其按时间正序排列(旧消息在前)
|
||||||
|
Collections.reverse(messages);
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Long> getChattedCompanionIds(Long userId) {
|
||||||
|
// 1. 查询用户所有活跃会话
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatSession> sessionWrapper = new LambdaQueryWrapper<>();
|
||||||
|
sessionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
|
||||||
|
.eq(KeyboardAiChatSession::getIsActive, true);
|
||||||
|
List<KeyboardAiChatSession> activeSessions = sessionService.list(sessionWrapper);
|
||||||
|
|
||||||
|
// 2. 如果没有活跃会话,返回空列表
|
||||||
|
if (activeSessions == null || activeSessions.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 提取活跃会话的 sessionId 列表
|
||||||
|
List<Long> activeSessionIds = activeSessions.stream()
|
||||||
|
.map(KeyboardAiChatSession::getId)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
|
||||||
|
// 4. 查询这些会话中的消息,获取 companionId,按最近聊天时间倒序
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatMessage> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiChatMessage::getUserId, userId)
|
||||||
|
.in(KeyboardAiChatMessage::getSessionId, activeSessionIds)
|
||||||
|
.orderByDesc(KeyboardAiChatMessage::getCreatedAt);
|
||||||
|
|
||||||
|
List<KeyboardAiChatMessage> messages = this.list(queryWrapper);
|
||||||
|
|
||||||
|
// 5. 去重并保持顺序(按最近聊天时间)
|
||||||
|
return messages.stream()
|
||||||
|
.map(KeyboardAiChatMessage::getCompanionId)
|
||||||
|
.distinct()
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.yolo.keyborad.mapper.KeyboardAiChatSessionMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiChatSessionService;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/28 16:20
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class KeyboardAiChatSessionServiceImpl extends ServiceImpl<KeyboardAiChatSessionMapper, KeyboardAiChatSession> implements KeyboardAiChatSessionService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public KeyboardAiChatSession getOrCreateActiveSession(Long userId, Long companionId) {
|
||||||
|
// 查询当前活跃会话
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatSession> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiChatSession::getUserId, userId)
|
||||||
|
.eq(KeyboardAiChatSession::getCompanionId, companionId)
|
||||||
|
.eq(KeyboardAiChatSession::getIsActive, true);
|
||||||
|
KeyboardAiChatSession activeSession = this.getOne(queryWrapper);
|
||||||
|
|
||||||
|
if (activeSession != null) {
|
||||||
|
return activeSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在活跃会话,创建新会话
|
||||||
|
// 先查询该用户与该角色的最大版本号
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatSession> maxVersionWrapper = new LambdaQueryWrapper<>();
|
||||||
|
maxVersionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
|
||||||
|
.eq(KeyboardAiChatSession::getCompanionId, companionId)
|
||||||
|
.orderByDesc(KeyboardAiChatSession::getResetVersion)
|
||||||
|
.last("LIMIT 1");
|
||||||
|
KeyboardAiChatSession lastSession = this.getOne(maxVersionWrapper);
|
||||||
|
int newVersion = lastSession != null ? lastSession.getResetVersion() + 1 : 1;
|
||||||
|
|
||||||
|
// 创建新会话
|
||||||
|
KeyboardAiChatSession newSession = new KeyboardAiChatSession();
|
||||||
|
newSession.setUserId(userId);
|
||||||
|
newSession.setCompanionId(companionId);
|
||||||
|
newSession.setResetVersion(newVersion);
|
||||||
|
newSession.setIsActive(true);
|
||||||
|
newSession.setCreatedAt(new Date());
|
||||||
|
this.save(newSession);
|
||||||
|
|
||||||
|
return newSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getOrCreateActiveSessionId(Long userId, Long companionId) {
|
||||||
|
return getOrCreateActiveSession(userId, companionId).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public KeyboardAiChatSession resetSession(Long userId, Long companionId) {
|
||||||
|
Date now = new Date();
|
||||||
|
|
||||||
|
// 查询当前活跃会话
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatSession> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiChatSession::getUserId, userId)
|
||||||
|
.eq(KeyboardAiChatSession::getCompanionId, companionId)
|
||||||
|
.eq(KeyboardAiChatSession::getIsActive, true);
|
||||||
|
KeyboardAiChatSession activeSession = this.getOne(queryWrapper);
|
||||||
|
|
||||||
|
// 如果存在活跃会话,将其设为不活跃
|
||||||
|
if (activeSession != null) {
|
||||||
|
activeSession.setIsActive(false);
|
||||||
|
activeSession.setEndedAt(now);
|
||||||
|
this.updateById(activeSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询该用户与该角色的最大版本号
|
||||||
|
LambdaQueryWrapper<KeyboardAiChatSession> maxVersionWrapper = new LambdaQueryWrapper<>();
|
||||||
|
maxVersionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
|
||||||
|
.eq(KeyboardAiChatSession::getCompanionId, companionId)
|
||||||
|
.orderByDesc(KeyboardAiChatSession::getResetVersion)
|
||||||
|
.last("LIMIT 1");
|
||||||
|
KeyboardAiChatSession lastSession = this.getOne(maxVersionWrapper);
|
||||||
|
int newVersion = lastSession != null ? lastSession.getResetVersion() + 1 : 1;
|
||||||
|
|
||||||
|
// 创建新的活跃会话
|
||||||
|
KeyboardAiChatSession newSession = new KeyboardAiChatSession();
|
||||||
|
newSession.setUserId(userId);
|
||||||
|
newSession.setCompanionId(companionId);
|
||||||
|
newSession.setResetVersion(newVersion);
|
||||||
|
newSession.setIsActive(true);
|
||||||
|
newSession.setCreatedAt(now);
|
||||||
|
this.save(newSession);
|
||||||
|
|
||||||
|
return newSession;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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.ArrayList;
|
||||||
|
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> topCommentIds = entityPage.getRecords().stream()
|
||||||
|
.map(KeyboardAiCompanionComment::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 批量查询回复(每条一级评论取前3条回复)
|
||||||
|
Map<Long, List<KeyboardAiCompanionComment>> repliesMap = Map.of();
|
||||||
|
Map<Long, Long> replyCountMap = Map.of();
|
||||||
|
if (!topCommentIds.isEmpty()) {
|
||||||
|
// 查询所有回复
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionComment> replyWrapper = new LambdaQueryWrapper<>();
|
||||||
|
replyWrapper.in(KeyboardAiCompanionComment::getRootId, topCommentIds)
|
||||||
|
.eq(KeyboardAiCompanionComment::getStatus, 1)
|
||||||
|
.orderByAsc(KeyboardAiCompanionComment::getCreatedAt);
|
||||||
|
List<KeyboardAiCompanionComment> allReplies = this.list(replyWrapper);
|
||||||
|
|
||||||
|
// 按rootId分组,每组取前3条
|
||||||
|
repliesMap = allReplies.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getRootId));
|
||||||
|
|
||||||
|
// 统计回复数量
|
||||||
|
replyCountMap = repliesMap.entrySet().stream()
|
||||||
|
.collect(Collectors.toMap(Map.Entry::getKey, e -> (long) e.getValue().size()));
|
||||||
|
|
||||||
|
// 每组只保留前3条
|
||||||
|
repliesMap = repliesMap.entrySet().stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
Map.Entry::getKey,
|
||||||
|
e -> e.getValue().stream().limit(999).collect(Collectors.toList())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有需要查询的用户ID(一级评论 + 回复)
|
||||||
|
List<Long> userIds = new ArrayList<>(entityPage.getRecords().stream()
|
||||||
|
.map(KeyboardAiCompanionComment::getUserId)
|
||||||
|
.collect(Collectors.toSet()));
|
||||||
|
repliesMap.values().stream()
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.map(KeyboardAiCompanionComment::getUserId)
|
||||||
|
.forEach(uid -> {
|
||||||
|
if (!userIds.contains(uid)) {
|
||||||
|
userIds.add(uid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 批量查询用户信息
|
||||||
|
Map<Long, KeyboardUser> userMap = Map.of();
|
||||||
|
if (!userIds.isEmpty()) {
|
||||||
|
List<KeyboardUser> users = userService.listByIds(userIds);
|
||||||
|
userMap = users.stream().collect(Collectors.toMap(KeyboardUser::getId, u -> u));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有评论ID用于查询点赞状态(一级评论 + 回复)
|
||||||
|
List<Long> allCommentIds = new ArrayList<>(topCommentIds);
|
||||||
|
repliesMap.values().stream()
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.map(KeyboardAiCompanionComment::getId)
|
||||||
|
.forEach(allCommentIds::add);
|
||||||
|
Set<Long> likedCommentIds = commentLikeService.getLikedCommentIds(userId, allCommentIds);
|
||||||
|
|
||||||
|
// 转换为VO
|
||||||
|
Map<Long, KeyboardUser> finalUserMap = userMap;
|
||||||
|
Map<Long, List<KeyboardAiCompanionComment>> finalRepliesMap = repliesMap;
|
||||||
|
Map<Long, Long> finalReplyCountMap = replyCountMap;
|
||||||
|
|
||||||
|
return entityPage.convert(entity -> {
|
||||||
|
CommentVO vo = convertToVO(entity, finalUserMap, likedCommentIds);
|
||||||
|
|
||||||
|
// 填充回复列表
|
||||||
|
List<KeyboardAiCompanionComment> replies = finalRepliesMap.getOrDefault(entity.getId(), List.of());
|
||||||
|
List<CommentVO> replyVOs = replies.stream()
|
||||||
|
.map(reply -> convertToVO(reply, finalUserMap, likedCommentIds))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
vo.setReplies(replyVOs);
|
||||||
|
vo.setReplyCount(finalReplyCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
|
|
||||||
|
return vo;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将评论实体转换为VO
|
||||||
|
*/
|
||||||
|
private CommentVO convertToVO(KeyboardAiCompanionComment entity, Map<Long, KeyboardUser> userMap, Set<Long> likedCommentIds) {
|
||||||
|
CommentVO vo = new CommentVO();
|
||||||
|
vo.setId(entity.getId());
|
||||||
|
vo.setCompanionId(entity.getCompanionId());
|
||||||
|
vo.setUserId(entity.getUserId());
|
||||||
|
vo.setParentId(entity.getParentId());
|
||||||
|
vo.setRootId(entity.getRootId());
|
||||||
|
vo.setContent(entity.getContent());
|
||||||
|
vo.setLikeCount(entity.getLikeCount());
|
||||||
|
vo.setCreatedAt(entity.getCreatedAt());
|
||||||
|
vo.setLiked(likedCommentIds.contains(entity.getId()));
|
||||||
|
|
||||||
|
// 填充用户信息
|
||||||
|
KeyboardUser user = userMap.get(entity.getUserId());
|
||||||
|
if (user != null) {
|
||||||
|
vo.setUserName(user.getNickName());
|
||||||
|
vo.setUserAvatar(user.getAvatarUrl());
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.mapper.KeyboardAiCompanionLikeMapper;
|
||||||
|
import com.yolo.keyborad.mapper.KeyboardAiCompanionMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanionLike;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionLikeService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/27 18:18
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class KeyboardAiCompanionLikeServiceImpl extends ServiceImpl<KeyboardAiCompanionLikeMapper, KeyboardAiCompanionLike> implements KeyboardAiCompanionLikeService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionMapper companionMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasLiked(Long userId, Long companionId) {
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionLike> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiCompanionLike::getUserId, userId)
|
||||||
|
.eq(KeyboardAiCompanionLike::getCompanionId, companionId)
|
||||||
|
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
|
||||||
|
return this.count(queryWrapper) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<Long> getLikedCompanionIds(Long userId, List<Long> companionIds) {
|
||||||
|
if (companionIds == null || companionIds.isEmpty()) {
|
||||||
|
return new HashSet<>();
|
||||||
|
}
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionLike> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiCompanionLike::getUserId, userId)
|
||||||
|
.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
|
||||||
|
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
|
||||||
|
List<KeyboardAiCompanionLike> likes = this.list(queryWrapper);
|
||||||
|
return likes.stream()
|
||||||
|
.map(KeyboardAiCompanionLike::getCompanionId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public boolean toggleLike(Long userId, Long companionId) {
|
||||||
|
// 检查AI角色是否存在
|
||||||
|
KeyboardAiCompanion companion = companionMapper.selectById(companionId);
|
||||||
|
if (companion == null || companion.getStatus() != 1) {
|
||||||
|
throw new BusinessException(ErrorCode.COMPANION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找现有点赞记录
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionLike> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiCompanionLike::getUserId, userId)
|
||||||
|
.eq(KeyboardAiCompanionLike::getCompanionId, companionId);
|
||||||
|
KeyboardAiCompanionLike existingLike = this.getOne(queryWrapper);
|
||||||
|
|
||||||
|
Date now = new Date();
|
||||||
|
boolean isLiked;
|
||||||
|
|
||||||
|
if (existingLike != null) {
|
||||||
|
// 切换点赞状态
|
||||||
|
if (existingLike.getStatus() == 1) {
|
||||||
|
// 取消点赞
|
||||||
|
existingLike.setStatus((short) 0);
|
||||||
|
existingLike.setUpdatedAt(now);
|
||||||
|
this.updateById(existingLike);
|
||||||
|
isLiked = false;
|
||||||
|
} else {
|
||||||
|
// 重新点赞
|
||||||
|
existingLike.setStatus((short) 1);
|
||||||
|
existingLike.setUpdatedAt(now);
|
||||||
|
this.updateById(existingLike);
|
||||||
|
isLiked = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 新增点赞记录
|
||||||
|
KeyboardAiCompanionLike like = new KeyboardAiCompanionLike();
|
||||||
|
like.setUserId(userId);
|
||||||
|
like.setCompanionId(companionId);
|
||||||
|
like.setStatus((short) 1);
|
||||||
|
like.setCreatedAt(now);
|
||||||
|
like.setUpdatedAt(now);
|
||||||
|
this.save(like);
|
||||||
|
isLiked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLiked;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Long> getAllLikedCompanionIds(Long userId) {
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionLike> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiCompanionLike::getUserId, userId)
|
||||||
|
.eq(KeyboardAiCompanionLike::getStatus, (short) 1)
|
||||||
|
.select(KeyboardAiCompanionLike::getCompanionId);
|
||||||
|
List<KeyboardAiCompanionLike> likes = this.list(queryWrapper);
|
||||||
|
return likes.stream()
|
||||||
|
.map(KeyboardAiCompanionLike::getCompanionId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.model.dto.companion.CompanionReportReq;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.yolo.keyborad.mapper.KeyboardAiCompanionReportMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanionReport;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionReportService;
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/29 16:17
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class KeyboardAiCompanionReportServiceImpl extends ServiceImpl<KeyboardAiCompanionReportMapper, KeyboardAiCompanionReport> implements KeyboardAiCompanionReportService{
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionService aiCompanionService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long reportCompanion(Long userId, CompanionReportReq req) {
|
||||||
|
// 校验 companionId 不为空
|
||||||
|
if (req.getCompanionId() == null) {
|
||||||
|
throw new BusinessException(ErrorCode.REPORT_COMPANION_ID_EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 reportTypes 不为空
|
||||||
|
if (req.getReportTypes() == null || req.getReportTypes().isEmpty()) {
|
||||||
|
throw new BusinessException(ErrorCode.REPORT_TYPE_EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验每个 reportType 在有效范围内(1,2,3,4,5,99)
|
||||||
|
List<Short> validTypes = List.of((short) 1, (short) 2, (short) 3, (short) 4, (short) 5, (short) 99);
|
||||||
|
for (Short type : req.getReportTypes()) {
|
||||||
|
if (!validTypes.contains(type)) {
|
||||||
|
throw new BusinessException(ErrorCode.REPORT_TYPE_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 AI 角色是否存在
|
||||||
|
KeyboardAiCompanion companion = aiCompanionService.getById(req.getCompanionId());
|
||||||
|
if (companion == null) {
|
||||||
|
throw new BusinessException(ErrorCode.COMPANION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建举报记录
|
||||||
|
KeyboardAiCompanionReport report = new KeyboardAiCompanionReport();
|
||||||
|
report.setUserId(userId);
|
||||||
|
report.setCompanionId(req.getCompanionId());
|
||||||
|
|
||||||
|
// 将 List<Short> 转换为逗号分隔的字符串
|
||||||
|
String reportTypeStr = req.getReportTypes().stream()
|
||||||
|
.map(String::valueOf)
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
report.setReportType(reportTypeStr);
|
||||||
|
|
||||||
|
report.setReportDesc(req.getReportDesc());
|
||||||
|
report.setChatContext(req.getChatContext());
|
||||||
|
report.setEvidenceImageUrl(req.getEvidenceImageUrl());
|
||||||
|
report.setStatus((short) 0); // 待处理
|
||||||
|
report.setCreatedAt(new Date());
|
||||||
|
|
||||||
|
// 保存并返回 ID
|
||||||
|
this.save(report);
|
||||||
|
return report.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
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.entity.KeyboardAiCompanionComment;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanionLike;
|
||||||
|
import com.yolo.keyborad.model.vo.AiCompanionVO;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionCommentService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionLikeService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiChatMessageService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardAiCompanion;
|
||||||
|
import com.yolo.keyborad.mapper.KeyboardAiCompanionMapper;
|
||||||
|
import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2026/1/26 13:51
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompanionMapper, KeyboardAiCompanion> implements KeyboardAiCompanionService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionLikeService companionLikeService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiCompanionCommentService companionCommentService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardAiChatMessageService chatMessageService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<AiCompanionVO> pageList(Integer pageNum, Integer pageSize) {
|
||||||
|
Page<KeyboardAiCompanion> page = new Page<>(pageNum, pageSize);
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiCompanion::getStatus, 1)
|
||||||
|
.eq(KeyboardAiCompanion::getVisibility, 1)
|
||||||
|
.orderByDesc(KeyboardAiCompanion::getSortOrder)
|
||||||
|
.orderByDesc(KeyboardAiCompanion::getPopularityScore);
|
||||||
|
IPage<KeyboardAiCompanion> entityPage = this.page(page, queryWrapper);
|
||||||
|
|
||||||
|
// 获取所有角色ID
|
||||||
|
List<Long> companionIds = entityPage.getRecords().stream()
|
||||||
|
.map(KeyboardAiCompanion::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 批量统计点赞数
|
||||||
|
Map<Long, Long> likeCountMap = Map.of();
|
||||||
|
if (!companionIds.isEmpty()) {
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
|
||||||
|
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
|
||||||
|
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
|
||||||
|
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
|
||||||
|
likeCountMap = likes.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量统计评论数
|
||||||
|
Map<Long, Long> commentCountMap = Map.of();
|
||||||
|
if (!companionIds.isEmpty()) {
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
|
||||||
|
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
|
||||||
|
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
|
||||||
|
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
|
||||||
|
commentCountMap = comments.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为VO并填充统计数据
|
||||||
|
Map<Long, Long> finalLikeCountMap = likeCountMap;
|
||||||
|
Map<Long, Long> finalCommentCountMap = commentCountMap;
|
||||||
|
return entityPage.convert(entity -> {
|
||||||
|
AiCompanionVO vo = BeanUtil.copyProperties(entity, AiCompanionVO.class);
|
||||||
|
vo.setLikeCount(finalLikeCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
|
vo.setCommentCount(finalCommentCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
|
return vo;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<AiCompanionVO> pageListWithLikeStatus(Long userId, Integer pageNum, Integer pageSize) {
|
||||||
|
Page<KeyboardAiCompanion> page = new Page<>(pageNum, pageSize);
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.eq(KeyboardAiCompanion::getStatus, 1)
|
||||||
|
.eq(KeyboardAiCompanion::getVisibility, 1)
|
||||||
|
.orderByDesc(KeyboardAiCompanion::getSortOrder)
|
||||||
|
.orderByDesc(KeyboardAiCompanion::getPopularityScore);
|
||||||
|
IPage<KeyboardAiCompanion> entityPage = this.page(page, queryWrapper);
|
||||||
|
|
||||||
|
// 获取所有角色ID
|
||||||
|
List<Long> companionIds = entityPage.getRecords().stream()
|
||||||
|
.map(KeyboardAiCompanion::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 批量统计点赞数
|
||||||
|
Map<Long, Long> likeCountMap = Map.of();
|
||||||
|
if (!companionIds.isEmpty()) {
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
|
||||||
|
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
|
||||||
|
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
|
||||||
|
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
|
||||||
|
likeCountMap = likes.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量统计评论数
|
||||||
|
Map<Long, Long> commentCountMap = Map.of();
|
||||||
|
if (!companionIds.isEmpty()) {
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
|
||||||
|
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
|
||||||
|
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
|
||||||
|
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
|
||||||
|
commentCountMap = comments.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户已点赞的角色ID
|
||||||
|
Set<Long> likedCompanionIds = companionLikeService.getLikedCompanionIds(userId, companionIds);
|
||||||
|
|
||||||
|
// 转换为VO并填充统计数据和点赞状态
|
||||||
|
Map<Long, Long> finalLikeCountMap = likeCountMap;
|
||||||
|
Map<Long, Long> finalCommentCountMap = commentCountMap;
|
||||||
|
return entityPage.convert(entity -> {
|
||||||
|
AiCompanionVO vo = BeanUtil.copyProperties(entity, AiCompanionVO.class);
|
||||||
|
vo.setLikeCount(finalLikeCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
|
vo.setCommentCount(finalCommentCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
|
vo.setLiked(likedCompanionIds.contains(entity.getId()));
|
||||||
|
return vo;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AiCompanionVO> getLikedCompanions(Long userId) {
|
||||||
|
// 获取用户点赞过的所有AI角色ID
|
||||||
|
List<Long> likedCompanionIds = companionLikeService.getAllLikedCompanionIds(userId);
|
||||||
|
if (likedCompanionIds.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询这些AI角色的详细信息(只返回已上线且可见的)
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.in(KeyboardAiCompanion::getId, likedCompanionIds)
|
||||||
|
.eq(KeyboardAiCompanion::getStatus, 1)
|
||||||
|
.eq(KeyboardAiCompanion::getVisibility, 1)
|
||||||
|
.orderByDesc(KeyboardAiCompanion::getSortOrder)
|
||||||
|
.orderByDesc(KeyboardAiCompanion::getPopularityScore);
|
||||||
|
List<KeyboardAiCompanion> companions = this.list(queryWrapper);
|
||||||
|
|
||||||
|
if (companions.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取实际查询到的角色ID
|
||||||
|
List<Long> companionIds = companions.stream()
|
||||||
|
.map(KeyboardAiCompanion::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 批量统计点赞数
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
|
||||||
|
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
|
||||||
|
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
|
||||||
|
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
|
||||||
|
Map<Long, Long> likeCountMap = likes.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
|
||||||
|
|
||||||
|
// 批量统计评论数
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
|
||||||
|
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
|
||||||
|
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
|
||||||
|
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
|
||||||
|
Map<Long, Long> commentCountMap = comments.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
|
||||||
|
|
||||||
|
// 转换为VO并填充统计数据
|
||||||
|
return companions.stream().map(entity -> {
|
||||||
|
AiCompanionVO vo = BeanUtil.copyProperties(entity, AiCompanionVO.class);
|
||||||
|
vo.setLikeCount(likeCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
|
vo.setCommentCount(commentCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
|
vo.setLiked(true); // 用户已点赞
|
||||||
|
return vo;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AiCompanionVO> getChattedCompanions(Long userId) {
|
||||||
|
// 获取用户聊过天的所有AI角色ID
|
||||||
|
List<Long> chattedCompanionIds = chatMessageService.getChattedCompanionIds(userId);
|
||||||
|
if (chattedCompanionIds.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询这些AI角色的详细信息(只返回已上线且可见的)
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.in(KeyboardAiCompanion::getId, chattedCompanionIds)
|
||||||
|
.eq(KeyboardAiCompanion::getStatus, 1)
|
||||||
|
.eq(KeyboardAiCompanion::getVisibility, 1);
|
||||||
|
List<KeyboardAiCompanion> companions = this.list(queryWrapper);
|
||||||
|
|
||||||
|
if (companions.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取实际查询到的角色ID
|
||||||
|
List<Long> companionIds = companions.stream()
|
||||||
|
.map(KeyboardAiCompanion::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 批量统计点赞数
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
|
||||||
|
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
|
||||||
|
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
|
||||||
|
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
|
||||||
|
Map<Long, Long> likeCountMap = likes.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
|
||||||
|
|
||||||
|
// 批量统计评论数
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
|
||||||
|
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
|
||||||
|
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
|
||||||
|
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
|
||||||
|
Map<Long, Long> commentCountMap = comments.stream()
|
||||||
|
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
|
||||||
|
|
||||||
|
// 获取当前用户已点赞的角色ID
|
||||||
|
Set<Long> likedCompanionIds = companionLikeService.getLikedCompanionIds(userId, companionIds);
|
||||||
|
|
||||||
|
// 转换为VO并填充统计数据,保持原有顺序(按最近聊天时间)
|
||||||
|
Map<Long, KeyboardAiCompanion> companionMap = companions.stream()
|
||||||
|
.collect(Collectors.toMap(KeyboardAiCompanion::getId, c -> c));
|
||||||
|
|
||||||
|
return chattedCompanionIds.stream()
|
||||||
|
.filter(companionMap::containsKey)
|
||||||
|
.map(id -> {
|
||||||
|
KeyboardAiCompanion entity = companionMap.get(id);
|
||||||
|
AiCompanionVO vo = BeanUtil.copyProperties(entity, AiCompanionVO.class);
|
||||||
|
vo.setLikeCount(likeCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
|
vo.setCommentCount(commentCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
|
vo.setLiked(likedCompanionIds.contains(entity.getId()));
|
||||||
|
return vo;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiCompanionVO getCompanionById(Long userId, Long companionId) {
|
||||||
|
// 查询AI角色
|
||||||
|
KeyboardAiCompanion companion = this.getById(companionId);
|
||||||
|
if (companion == null) {
|
||||||
|
throw new BusinessException(ErrorCode.COMPANION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (companion.getStatus() != 1 || companion.getVisibility() != 1) {
|
||||||
|
throw new BusinessException(ErrorCode.COMPANION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计点赞数
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
|
||||||
|
likeWrapper.eq(KeyboardAiCompanionLike::getCompanionId, companionId)
|
||||||
|
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
|
||||||
|
long likeCount = companionLikeService.count(likeWrapper);
|
||||||
|
|
||||||
|
// 统计评论数
|
||||||
|
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
|
||||||
|
commentWrapper.eq(KeyboardAiCompanionComment::getCompanionId, companionId)
|
||||||
|
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
|
||||||
|
long commentCount = companionCommentService.count(commentWrapper);
|
||||||
|
|
||||||
|
// 获取当前用户点赞状态
|
||||||
|
boolean liked = companionLikeService.hasLiked(userId, companionId);
|
||||||
|
|
||||||
|
// 转换为VO
|
||||||
|
AiCompanionVO vo = BeanUtil.copyProperties(companion, AiCompanionVO.class);
|
||||||
|
vo.setLikeCount((int) likeCount);
|
||||||
|
vo.setCommentCount((int) commentCount);
|
||||||
|
vo.setLiked(liked);
|
||||||
|
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -288,4 +288,22 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
|
|||||||
this.addUserCharacter(keyboardUserCharacterAddDTO, userId);
|
this.addUserCharacter(keyboardUserCharacterAddDTO, userId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public KeyboardCharacterRespVO getDetailById(Long id) {
|
||||||
|
// 根据ID获取人设信息,优先从缓存获取
|
||||||
|
KeyboardCharacter character = this.getById(id);
|
||||||
|
// 将实体对象转换为响应对象
|
||||||
|
KeyboardCharacterRespVO respVO = BeanUtil.copyProperties(character, KeyboardCharacterRespVO.class);
|
||||||
|
// 判断当前用户是否已添加该人设
|
||||||
|
long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
// 查询用户人设关联表,判断当前用户是否已添加该人设
|
||||||
|
KeyboardUserCharacter userCharacter = keyboardUserCharacterMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<KeyboardUserCharacter>()
|
||||||
|
.eq(KeyboardUserCharacter::getCharacterId, id)
|
||||||
|
.eq(KeyboardUserCharacter::getUserId, userId));
|
||||||
|
// 设置是否已添加的标记
|
||||||
|
respVO.setAdded(userCharacter != null);
|
||||||
|
return respVO;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapp
|
|||||||
import com.yolo.keyborad.model.entity.KeyboardThemePurchase;
|
import com.yolo.keyborad.model.entity.KeyboardThemePurchase;
|
||||||
import com.yolo.keyborad.service.KeyboardThemePurchaseService;
|
import com.yolo.keyborad.service.KeyboardThemePurchaseService;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.annotation.Lazy;
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -22,17 +24,25 @@ import com.yolo.keyborad.service.KeyboardThemesService;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper, KeyboardThemes> implements KeyboardThemesService {
|
public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper, KeyboardThemes> implements KeyboardThemesService {
|
||||||
|
|
||||||
|
private static final String THEME_STYLE_KEY = "theme:style:";
|
||||||
|
private static final String THEME_ALL_KEY = "theme:style:all";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
@Lazy // 延迟加载,打破循环依赖
|
@Lazy // 延迟加载,打破循环依赖
|
||||||
private KeyboardThemePurchaseService purchaseService;
|
private KeyboardThemePurchaseService purchaseService;
|
||||||
|
|
||||||
|
@Resource(name = "objectRedisTemplate")
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据风格查询主题列表
|
* 根据风格查询主题列表
|
||||||
* <p>查询规则:</p>
|
* <p>查询规则:</p>
|
||||||
* <ul>
|
* <ul>
|
||||||
|
* <li>优先从Redis缓存读取主题列表</li>
|
||||||
* <li>当themeStyle为9999时,查询所有主题并按排序字段升序排列</li>
|
* <li>当themeStyle为9999时,查询所有主题并按排序字段升序排列</li>
|
||||||
* <li>其他情况下,查询指定风格的主题</li>
|
* <li>其他情况下,查询指定风格的主题</li>
|
||||||
* <li>查询结果均过滤已删除和未启用的主题</li>
|
* <li>查询结果均过滤已删除和未启用的主题</li>
|
||||||
@@ -44,24 +54,45 @@ public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper,
|
|||||||
* @return 主题列表,包含主题详情和购买状态
|
* @return 主题列表,包含主题详情和购买状态
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
public List<KeyboardThemesRespVO> selectThemesByStyle(Long themeStyle, Long userId) {
|
public List<KeyboardThemesRespVO> selectThemesByStyle(Long themeStyle, Long userId) {
|
||||||
// 根据风格参数查询主题列表
|
// 尝试从Redis缓存读取
|
||||||
List<KeyboardThemes> themesList;
|
String cacheKey = themeStyle == 9999 ? THEME_ALL_KEY : THEME_STYLE_KEY + themeStyle;
|
||||||
|
List<KeyboardThemesRespVO> themesList = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object cached = redisTemplate.opsForValue().get(cacheKey);
|
||||||
|
if (cached != null) {
|
||||||
|
themesList = (List<KeyboardThemesRespVO>) cached;
|
||||||
|
log.debug("从缓存读取风格{}的主题列表,共{}个", themeStyle, themesList.size());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("读取主题缓存失败,将从数据库查询", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存未命中,从数据库查询
|
||||||
|
if (themesList == null) {
|
||||||
|
List<KeyboardThemes> themesFromDb;
|
||||||
if (themeStyle == 9999) {
|
if (themeStyle == 9999) {
|
||||||
// 查询所有主题,按排序字段升序
|
// 查询所有主题,按排序字段升序
|
||||||
themesList = this.lambdaQuery()
|
themesFromDb = this.lambdaQuery()
|
||||||
.eq(KeyboardThemes::getDeleted, false)
|
.eq(KeyboardThemes::getDeleted, false)
|
||||||
.eq(KeyboardThemes::getThemeStatus, true)
|
.eq(KeyboardThemes::getThemeStatus, true)
|
||||||
.orderByAsc(KeyboardThemes::getSort)
|
.orderByAsc(KeyboardThemes::getSort)
|
||||||
.list();
|
.list();
|
||||||
} else {
|
} else {
|
||||||
// 查询指定风格的主题
|
// 查询指定风格的主题
|
||||||
themesList = this.lambdaQuery()
|
themesFromDb = this.lambdaQuery()
|
||||||
.eq(KeyboardThemes::getDeleted, false)
|
.eq(KeyboardThemes::getDeleted, false)
|
||||||
.eq(KeyboardThemes::getThemeStatus, true)
|
.eq(KeyboardThemes::getThemeStatus, true)
|
||||||
.eq(KeyboardThemes::getThemeStyle, themeStyle)
|
.eq(KeyboardThemes::getThemeStyle, themeStyle)
|
||||||
.list();
|
.list();
|
||||||
}
|
}
|
||||||
|
themesList = themesFromDb.stream()
|
||||||
|
.map(theme -> BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
log.debug("从数据库读取风格{}的主题列表,共{}个", themeStyle, themesList.size());
|
||||||
|
}
|
||||||
|
|
||||||
// 查询用户已购买的主题ID集合
|
// 查询用户已购买的主题ID集合
|
||||||
Set<Long> purchasedThemeIds = purchaseService.lambdaQuery()
|
Set<Long> purchasedThemeIds = purchaseService.lambdaQuery()
|
||||||
@@ -72,7 +103,7 @@ public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper,
|
|||||||
.map(KeyboardThemePurchase::getThemeId)
|
.map(KeyboardThemePurchase::getThemeId)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
// 转换为VO并设置购买状态
|
// 设置购买状态并返回
|
||||||
return themesList.stream().map(theme -> {
|
return themesList.stream().map(theme -> {
|
||||||
KeyboardThemesRespVO vo = BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class);
|
KeyboardThemesRespVO vo = BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class);
|
||||||
vo.setIsPurchased(purchasedThemeIds.contains(theme.getId()));
|
vo.setIsPurchased(purchasedThemeIds.contains(theme.getId()));
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package com.yolo.keyborad.service.impl;
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
import cn.hutool.core.util.RandomUtil;
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import com.yolo.keyborad.common.ErrorCode;
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.config.AppConfig;
|
||||||
|
import com.yolo.keyborad.config.NacosAppConfigCenter;
|
||||||
import com.yolo.keyborad.exception.BusinessException;
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.model.vo.user.InviteCodeRespVO;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -20,6 +24,69 @@ import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
|
|||||||
@Service
|
@Service
|
||||||
public class KeyboardUserInviteCodesServiceImpl extends ServiceImpl<KeyboardUserInviteCodesMapper, KeyboardUserInviteCodes> implements KeyboardUserInviteCodesService{
|
public class KeyboardUserInviteCodesServiceImpl extends ServiceImpl<KeyboardUserInviteCodesMapper, KeyboardUserInviteCodes> implements KeyboardUserInviteCodesService{
|
||||||
|
|
||||||
|
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||||
|
|
||||||
|
public KeyboardUserInviteCodesServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
||||||
|
this.cfgHolder = cfgHolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InviteCodeRespVO getUserInviteCode(Long userId) {
|
||||||
|
QueryWrapper<KeyboardUserInviteCodes> queryWrapper = new QueryWrapper<>();
|
||||||
|
queryWrapper.eq("owner_user_id", userId);
|
||||||
|
KeyboardUserInviteCodes one = this.getOne(queryWrapper);
|
||||||
|
if (one == null) {
|
||||||
|
one = createInviteCode(userId);
|
||||||
|
}
|
||||||
|
InviteCodeRespVO inviteCodeRespVO = BeanUtil.copyProperties(one, InviteCodeRespVO.class);
|
||||||
|
AppConfig appConfig = cfgHolder.getRef().get();
|
||||||
|
inviteCodeRespVO.setH5Link(appConfig.getInviteConfig().getH5Link());
|
||||||
|
return inviteCodeRespVO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public KeyboardUserInviteCodes createInviteCode(Long userId) {
|
||||||
|
// 生成唯一的邀请码
|
||||||
|
String code;
|
||||||
|
int maxRetries = 10;
|
||||||
|
int retryCount = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
// 生成8位字母数字组合的邀请码
|
||||||
|
code = RandomUtil.randomString(8).toUpperCase();
|
||||||
|
|
||||||
|
// 检查邀请码是否已存在
|
||||||
|
QueryWrapper<KeyboardUserInviteCodes> queryWrapper = new QueryWrapper<>();
|
||||||
|
queryWrapper.eq("code", code);
|
||||||
|
KeyboardUserInviteCodes existingCode = this.getOne(queryWrapper);
|
||||||
|
|
||||||
|
if (existingCode == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
retryCount++;
|
||||||
|
} while (retryCount < maxRetries);
|
||||||
|
|
||||||
|
// 创建邀请码实体
|
||||||
|
KeyboardUserInviteCodes inviteCode = new KeyboardUserInviteCodes();
|
||||||
|
inviteCode.setCode(code);
|
||||||
|
inviteCode.setOwnerUserId(userId);
|
||||||
|
inviteCode.setStatus((short) 1); // 启用状态
|
||||||
|
inviteCode.setCreatedAt(new Date());
|
||||||
|
inviteCode.setExpiresAt(null); // 永久有效
|
||||||
|
inviteCode.setMaxUses(null); // 不限次数
|
||||||
|
inviteCode.setUsedCount(0); // 初始使用次数为0
|
||||||
|
inviteCode.setInviteType("USER"); // 默认为普通用户邀请码
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
this.save(inviteCode);
|
||||||
|
InviteCodeRespVO inviteCodeRespVO = BeanUtil.copyProperties(inviteCode, InviteCodeRespVO.class);
|
||||||
|
|
||||||
|
return inviteCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public KeyboardUserInviteCodes validateInviteCode(String code) {
|
public KeyboardUserInviteCodes validateInviteCode(String code) {
|
||||||
// 查询邀请码
|
// 查询邀请码
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
wallet.setCreatedAt(new Date());
|
wallet.setCreatedAt(new Date());
|
||||||
wallet.setUpdatedAt(new Date());
|
wallet.setUpdatedAt(new Date());
|
||||||
walletService.save(wallet);
|
walletService.save(wallet);
|
||||||
|
|
||||||
// 初始化用户免费使用次数配额
|
// 初始化用户免费使用次数配额
|
||||||
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
|
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
|
||||||
quotaTotal.setUserId(keyboardUser.getId());
|
quotaTotal.setUserId(keyboardUser.getId());
|
||||||
@@ -114,6 +113,8 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
quotaTotal.setUpdatedAt(new Date());
|
quotaTotal.setUpdatedAt(new Date());
|
||||||
quotaTotalService.save(quotaTotal);
|
quotaTotalService.save(quotaTotal);
|
||||||
|
|
||||||
|
inviteCodesService.createInviteCode(keyboardUser.getId());
|
||||||
|
|
||||||
log.info("User registered with Apple Sign-In, userId={}, freeQuota={}",
|
log.info("User registered with Apple Sign-In, userId={}, freeQuota={}",
|
||||||
keyboardUser.getId(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
|
keyboardUser.getId(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
|
||||||
|
|
||||||
@@ -190,7 +191,8 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
}
|
}
|
||||||
|
|
||||||
KeyboardUser keyboardUser = BeanUtil.copyProperties(keyboardUserReq, KeyboardUser.class);
|
KeyboardUser keyboardUser = BeanUtil.copyProperties(keyboardUserReq, KeyboardUser.class);
|
||||||
Integer i = keyboardUserMapper.updateByuid(keyboardUser);
|
keyboardUser.setId(loginIdAsLong);
|
||||||
|
int i = keyboardUserMapper.updateById(keyboardUser);
|
||||||
if (i <=0 ) {
|
if (i <=0 ) {
|
||||||
throw new BusinessException(ErrorCode.USER_INFO_UPDATE_FAILED);
|
throw new BusinessException(ErrorCode.USER_INFO_UPDATE_FAILED);
|
||||||
}
|
}
|
||||||
@@ -252,6 +254,7 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
quotaTotal.setCreatedAt(new Date());
|
quotaTotal.setCreatedAt(new Date());
|
||||||
quotaTotal.setUpdatedAt(new Date());
|
quotaTotal.setUpdatedAt(new Date());
|
||||||
quotaTotalService.save(quotaTotal);
|
quotaTotalService.save(quotaTotal);
|
||||||
|
inviteCodesService.createInviteCode(keyboardUser.getId());
|
||||||
|
|
||||||
// 处理邀请码绑定
|
// 处理邀请码绑定
|
||||||
if (userRegisterDTO.getInviteCode() != null && !userRegisterDTO.getInviteCode().trim().isEmpty()) {
|
if (userRegisterDTO.getInviteCode() != null && !userRegisterDTO.getInviteCode().trim().isEmpty()) {
|
||||||
@@ -268,14 +271,23 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
userInvite.setBoundAt(new Date());
|
userInvite.setBoundAt(new Date());
|
||||||
userInvite.setBindIp(request.getRemoteAddr());
|
userInvite.setBindIp(request.getRemoteAddr());
|
||||||
userInvite.setBindUserAgent(request.getHeader("User-Agent"));
|
userInvite.setBindUserAgent(request.getHeader("User-Agent"));
|
||||||
|
// 记录邀请码类型快照(用户/租户)
|
||||||
|
userInvite.setInviteType(inviteCode.getInviteType());
|
||||||
|
userInvite.setInviteCode(inviteCode.getCode());
|
||||||
|
// 如果是租户邀请码,记录租户ID
|
||||||
|
if ("AGENT".equals(inviteCode.getInviteType()) && inviteCode.getOwnerTenantId() != null) {
|
||||||
|
userInvite.setProfitTenantId(inviteCode.getOwnerTenantId());
|
||||||
|
userInvite.setInviterTenantId(inviteCode.getOwnerTenantId());
|
||||||
|
userInvite.setProfitEmployeeId(inviteCode.getOwnerSystemUserId());
|
||||||
|
}
|
||||||
userInvitesService.save(userInvite);
|
userInvitesService.save(userInvite);
|
||||||
|
|
||||||
// 更新邀请码使用次数
|
// 更新邀请码使用次数
|
||||||
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
|
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
|
||||||
inviteCodesService.updateById(inviteCode);
|
inviteCodesService.updateById(inviteCode);
|
||||||
|
|
||||||
log.info("User bound to invite code, userId={}, inviteCodeId={}, inviterUserId={}",
|
log.info("User bound to invite code, userId={}, inviteCodeId={}, inviterUserId={}, inviteType={}",
|
||||||
keyboardUser.getId(), inviteCode.getId(), inviteCode.getOwnerUserId());
|
keyboardUser.getId(), inviteCode.getId(), inviteCode.getOwnerUserId(), inviteCode.getInviteType());
|
||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
// 邀请码验证失败,记录日志但不影响注册流程
|
// 邀请码验证失败,记录日志但不影响注册流程
|
||||||
log.warn("Failed to bind invite code for user {}: {}", keyboardUser.getId(), e.getMessage());
|
log.warn("Failed to bind invite code for user {}: {}", keyboardUser.getId(), e.getMessage());
|
||||||
@@ -377,14 +389,23 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
userInvite.setBoundAt(new Date());
|
userInvite.setBoundAt(new Date());
|
||||||
userInvite.setBindIp(request.getRemoteAddr());
|
userInvite.setBindIp(request.getRemoteAddr());
|
||||||
userInvite.setBindUserAgent(request.getHeader("User-Agent"));
|
userInvite.setBindUserAgent(request.getHeader("User-Agent"));
|
||||||
|
// 记录邀请码类型快照(用户/租户)
|
||||||
|
userInvite.setInviteType(inviteCode.getInviteType());
|
||||||
|
userInvite.setInviteCode(inviteCode.getCode());
|
||||||
|
// 如果是租户邀请码,记录租户ID
|
||||||
|
if ("AGENT".equals(inviteCode.getInviteType()) && inviteCode.getOwnerTenantId() != null) {
|
||||||
|
userInvite.setProfitTenantId(inviteCode.getOwnerTenantId());
|
||||||
|
userInvite.setInviterTenantId(inviteCode.getOwnerTenantId());
|
||||||
|
userInvite.setProfitEmployeeId(inviteCode.getOwnerSystemUserId());
|
||||||
|
}
|
||||||
userInvitesService.save(userInvite);
|
userInvitesService.save(userInvite);
|
||||||
|
|
||||||
// 更新邀请码使用次数
|
// 更新邀请码使用次数
|
||||||
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
|
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
|
||||||
inviteCodesService.updateById(inviteCode);
|
inviteCodesService.updateById(inviteCode);
|
||||||
|
|
||||||
log.info("User bound invite code, userId={}, inviteCodeId={}, inviterUserId={}",
|
log.info("User bound invite code, userId={}, inviteCodeId={}, inviterUserId={}, inviteType={}",
|
||||||
userId, inviteCode.getId(), inviteCode.getOwnerUserId());
|
userId, inviteCode.getId(), inviteCode.getOwnerUserId(), inviteCode.getInviteType());
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ dromara:
|
|||||||
bucket-name: keyborad-resource #桶名称
|
bucket-name: keyborad-resource #桶名称
|
||||||
domain: https://resource.loveamorkey.com/ # 访问域名,注意末尾的'/',例如:https://abcd.s3.ap-east-1.amazonaws.com/
|
domain: https://resource.loveamorkey.com/ # 访问域名,注意末尾的'/',例如:https://abcd.s3.ap-east-1.amazonaws.com/
|
||||||
base-path: avatar/ # 基础路径
|
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 配置 (参考文档: https://sa-token.cc) ##############
|
||||||
sa-token:
|
sa-token:
|
||||||
@@ -91,3 +100,16 @@ nacos:
|
|||||||
server-addr: 127.0.0.1:8848
|
server-addr: 127.0.0.1:8848
|
||||||
group: DEFAULT_GROUP
|
group: DEFAULT_GROUP
|
||||||
data-id: keyboard_default-config.yaml
|
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
|
||||||
|
|
||||||
|
deepgram:
|
||||||
|
api-key: 9c792eb63a65d644cbc95785155754cd1e84f8cf
|
||||||
|
model: nova-2
|
||||||
|
language: en
|
||||||
|
smart-format: true
|
||||||
|
punctuate: true
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
spring:
|
spring:
|
||||||
ai:
|
ai:
|
||||||
openai:
|
openai:
|
||||||
# api-key: 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
|
api-key: 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
|
||||||
api-key: sk-cf112f49cf4d4138a49575cda1f852b4
|
# api-key: sk-cf112f49cf4d4138a49575cda1f852b4
|
||||||
# base-url: https://gateway.ai.cloudflare.com/v1/b632a61caa85401f63c9b32eef3a74c8/aigetway/openrouter
|
base-url: https://gateway.ai.cloudflare.com/v1/b632a61caa85401f63c9b32eef3a74c8/aigetway/openrouter
|
||||||
base-url: https://dashscope-intl.aliyuncs.com/compatible-mode/
|
# base-url: https://dashscope-intl.aliyuncs.com/compatible-mode/
|
||||||
chat:
|
chat:
|
||||||
options:
|
options:
|
||||||
model: qwen-plus
|
model: google/gemini-2.5-flash-lite
|
||||||
embedding:
|
embedding:
|
||||||
options:
|
options:
|
||||||
model: text-embedding-v4
|
model: text-embedding-v4
|
||||||
|
|||||||
20
src/main/resources/mapper/KeyboardAiChatMessageMapper.xml
Normal file
20
src/main/resources/mapper/KeyboardAiChatMessageMapper.xml
Normal 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>
|
||||||
19
src/main/resources/mapper/KeyboardAiChatSessionMapper.xml
Normal file
19
src/main/resources/mapper/KeyboardAiChatSessionMapper.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.yolo.keyborad.mapper.KeyboardAiChatSessionMapper">
|
||||||
|
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardAiChatSession">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
<!--@Table keyboard_ai_chat_session-->
|
||||||
|
<id column="id" jdbcType="BIGINT" property="id" />
|
||||||
|
<result column="user_id" jdbcType="BIGINT" property="userId" />
|
||||||
|
<result column="companion_id" jdbcType="BIGINT" property="companionId" />
|
||||||
|
<result column="reset_version" jdbcType="INTEGER" property="resetVersion" />
|
||||||
|
<result column="is_active" jdbcType="BOOLEAN" property="isActive" />
|
||||||
|
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
|
||||||
|
<result column="ended_at" jdbcType="TIMESTAMP" property="endedAt" />
|
||||||
|
</resultMap>
|
||||||
|
<sql id="Base_Column_List">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
id, user_id, companion_id, reset_version, is_active, created_at, ended_at
|
||||||
|
</sql>
|
||||||
|
</mapper>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
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>
|
||||||
31
src/main/resources/mapper/KeyboardAiCompanionMapper.xml
Normal file
31
src/main/resources/mapper/KeyboardAiCompanionMapper.xml
Normal 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>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.yolo.keyborad.mapper.KeyboardAiCompanionReportMapper">
|
||||||
|
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardAiCompanionReport">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
<!--@Table keyboard_ai_companion_report-->
|
||||||
|
<id column="id" jdbcType="BIGINT" property="id" />
|
||||||
|
<result column="companion_id" jdbcType="BIGINT" property="companionId" />
|
||||||
|
<result column="user_id" jdbcType="BIGINT" property="userId" />
|
||||||
|
<result column="report_type" jdbcType="SMALLINT" property="reportType" />
|
||||||
|
<result column="report_desc" jdbcType="VARCHAR" property="reportDesc" />
|
||||||
|
<result column="chat_context" jdbcType="VARCHAR" property="chatContext" />
|
||||||
|
<result column="evidence_image_url" jdbcType="VARCHAR" property="evidenceImageUrl" />
|
||||||
|
<result column="status" jdbcType="SMALLINT" property="status" />
|
||||||
|
<result column="admin_remark" jdbcType="VARCHAR" property="adminRemark" />
|
||||||
|
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
|
||||||
|
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
|
||||||
|
</resultMap>
|
||||||
|
<sql id="Base_Column_List">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
id, companion_id, user_id, report_type, report_desc, chat_context, evidence_image_url,
|
||||||
|
"status", admin_remark, created_at, updated_at
|
||||||
|
</sql>
|
||||||
|
</mapper>
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
<result column="inviter_user_id" jdbcType="BIGINT" property="inviterUserId" />
|
<result column="inviter_user_id" jdbcType="BIGINT" property="inviterUserId" />
|
||||||
<result column="invitee_user_id" jdbcType="BIGINT" property="inviteeUserId" />
|
<result column="invitee_user_id" jdbcType="BIGINT" property="inviteeUserId" />
|
||||||
<result column="invite_code_id" jdbcType="BIGINT" property="inviteCodeId" />
|
<result column="invite_code_id" jdbcType="BIGINT" property="inviteCodeId" />
|
||||||
<result column="click_token" jdbcType="VARCHAR" property="clickToken" />
|
|
||||||
<result column="bind_type" jdbcType="SMALLINT" property="bindType" />
|
<result column="bind_type" jdbcType="SMALLINT" property="bindType" />
|
||||||
<result column="bound_at" jdbcType="TIMESTAMP" property="boundAt" />
|
<result column="bound_at" jdbcType="TIMESTAMP" property="boundAt" />
|
||||||
<result column="bind_ip" jdbcType="VARCHAR" property="bindIp" />
|
<result column="bind_ip" jdbcType="VARCHAR" property="bindIp" />
|
||||||
@@ -16,7 +15,7 @@
|
|||||||
</resultMap>
|
</resultMap>
|
||||||
<sql id="Base_Column_List">
|
<sql id="Base_Column_List">
|
||||||
<!--@mbg.generated-->
|
<!--@mbg.generated-->
|
||||||
id, inviter_user_id, invitee_user_id, invite_code_id, click_token, bind_type, bound_at,
|
id, inviter_user_id, invitee_user_id, invite_code_id, bind_type, bound_at,
|
||||||
bind_ip, bind_user_agent
|
bind_ip, bind_user_agent
|
||||||
</sql>
|
</sql>
|
||||||
</mapper>
|
</mapper>
|
||||||
Reference in New Issue
Block a user