Compare commits

..

77 Commits

Author SHA1 Message Date
392d9ecfe8 feat(ai-companion): 新增AI角色举报功能
- 新增举报接口 POST /ai-companion/report,支持多选举报类型
- 引入 KeyboardAiCompanionReportService 处理举报业务
- 补充举报相关错误码:类型无效、角色ID为空、类型为空
- 新增实体、DTO、Mapper、Service 及 XML 配置,完成举报数据持久化
2026-01-29 19:38:13 +08:00
6a773ee0ca fix(service): 修复聊天消息排序逻辑
在分页查询消息时,先按时间升序再按ID升序,确保顺序稳定一致
2026-01-29 14:32:16 +08:00
7d23b6be0f fix(service): 仅返回活跃会话中的聊天角色列表 2026-01-28 20:53:47 +08:00
408d4d4bc1 fix(service): 限制聊天查询仅活跃会话 2026-01-28 18:06:51 +08:00
ecab353802 feat(chat): 新增重置会话接口并优化主键策略
- ChatController 增加 /session/reset 端点,支持用户主动重置与 AI 角色的会话
- 会话重置逻辑:将当前活跃会话置为失效,并创建版本号递增的新会话
- 新增 SessionResetReq DTO 与 ChatSessionVO 返回视图
- KeyboardAiChatSession 主键生成策略由 AUTO 改为 ASSIGN_ID,适配分布式场景
2026-01-28 17:56:31 +08:00
234ea0c241 feat(chat): 新增会话管理支持多轮对话
- 引入 KeyboardAiChatSession 实体及对应 Mapper、Service
- 为 KeyboardAiChatMessage 增加 session_id 字段
- ChatServiceImpl 保存消息时绑定会话,支持按用户+角色获取或创建活跃会话
- 保证同一用户同一角色的连续对话归属同一会话,实现多轮上下文管理
2026-01-28 16:50:27 +08:00
e1aa1ce4e8 feat(service): 新增根据ID获取AI角色详情接口 2026-01-28 15:57:59 +08:00
c8d8046bf4 feat(ai-companion): 新增获取用户聊过天的AI角色列表接口 2026-01-28 15:50:15 +08:00
0e863288c8 feat(ai-companion): 新增获取用户已点赞AI角色列表接口 2026-01-28 15:30:48 +08:00
f28e6b7c39 fix(service): 延后VIP体验次数扣减至AI响应成功后 2026-01-27 21:11:45 +08:00
22e5041447 fix(ai-companion): 修复点赞状态与评论回复展示逻辑
- 分页查询接口新增当前用户点赞状态返回
- CommentVO 新增 replies 与 replyCount 字段支持嵌套回复
- 评论服务支持查询一级评论及其前 999 条回复
- 免登录白名单新增 /ai-companion/comment/page 接口
2026-01-27 19:42:44 +08:00
e68f1bea56 feat(ai-companion): 补充点赞与评论统计字段
- 在 AiCompanionVO 新增 likeCount、commentCount
- 分页接口批量聚合点赞/评论数并填充到 VO
- 减少 N+1 查询,提升列表接口性能
2026-01-27 18:37:47 +08:00
b6d124619e feat(ai-companion): 新增AI角色点赞功能
新增点赞/取消点赞接口,包含实体、Mapper、Service及DTO,支持用户点赞状态切换与异常处理。
2026-01-27 18:33:26 +08:00
6cf0275980 feat(speech): 新增语音转文字功能
新增 Deepgram 集成,支持音频文件上传、格式校验与转写;补充相关错误码并放行 /speech/transcribe 接口
2026-01-27 18:17:36 +08:00
f18217ba93 fix(chat): 为低VIP用户增加每日体验次数限制
- 在ChatServiceImpl.message()中新增VIP等级检查逻辑
- 使用Redis计数器实现每日额度控制,午夜自动重置
- 新增错误码VIP_TRIAL_LIMIT_REACHED(50030)
- 同步更新.gitignore忽略.omc目录
2026-01-26 22:02:44 +08:00
1decf0ac58 feat(vip): 新增vip等级字段及免费聊天次数配置 2026-01-26 21:39:12 +08:00
1523ea0fbd feat(ai-companion): 新增评论点赞功能及点赞状态查询 2026-01-26 21:31:32 +08:00
5a58c4ff38 feat(comment): 新增AI伴侣评论功能并补充相关错误码 2026-01-26 20:39:34 +08:00
ac9a352004 feat(chat): 为AI陪聊增加历史消息上下文支持
- ChatServiceImpl#message 现在会读取最近20条聊天记录作为LLM上下文
- 新增 callLLMWithHistory 方法,使用 Spring AI Message 构造对话历史
- KeyboardAiChatMessageService 新增 getRecentMessages 接口及实现,按时间正序返回指定条数消息
- 保持原有分页查询接口不变,仅补充上下文所需方法
2026-01-26 20:25:04 +08:00
aaf5d3bea4 feat(chat): 新增分页查询聊天记录接口 2026-01-26 18:38:51 +08:00
b887e52f55 feat(chat): 新增AI聊天记录持久化功能
新增KeyboardAiChatMessage实体及对应Mapper、Service,在ChatServiceImpl中同步对话时保存用户与AI消息到数据库,实现聊天记录持久化
2026-01-26 17:11:18 +08:00
6bb905bb30 feat(ai-companion): 新增AI伴侣模块及白名单路径 2026-01-26 16:25:39 +08:00
fd4c381d33 feat(ai-companion): 新增AI伴侣模块及白名单路径 2026-01-26 15:06:26 +08:00
8783a4c2af refactor(service): 用 RestClient 重写 ElevenLabs 调用并替换 UUID 工具类
- 将手写 HttpURLConnection 改为 Spring RestClient,精简 64 行冗余代码
- 引入 RestClientConfig 统一配置
- 统一使用 Hutool 的 IdUtil 生成文件名称
2026-01-26 13:49:50 +08:00
6a1bb50318 feat(chat): 集成 ElevenLabs TTS 并支持异步语音生成 2026-01-23 19:45:32 +08:00
bb3dcc56ff feat(service): 新增 WebSocket 实时语音转写与流式 TTS 全流程 2026-01-23 14:25:05 +08:00
8632dcd282 docs(readme): 重写项目文档,更新技术栈与功能说明
feat(character): 新增人设详情接口并优化获取逻辑
2026-01-07 16:17:57 +08:00
090cb65c0b fix(controller): 修复文件上传参数绑定与异常处理
- FileController:把 @RequestParam 改成 @RequestPart 并去掉多余注解
- GlobalExceptionHandler:新增 MissingServletRequestPartException 拦截,返回 FILE_IS_EMPTY 错误码
- RequestBodyCacheFilter:跳过 multipart 请求,避免文件上传被缓存过滤器破坏
- UserServiceImpl:修正更新语句为 updateById,防止字段丢失
2026-01-05 20:26:37 +08:00
a73a92c0c2 fix(config): 修复包名大小写并优化 Maven 构建配置
- 统一 interceptor 包名为小写
- 修正测试接口拼写 testSearchText
- 升级编译插件并显式声明 JDK17 与 Lombok 版本
- 将本地 Claude 记录文件加入忽略列表
2026-01-04 15:51:40 +08:00
2cdbdfeaf2 fix(config): 将分页拦截器数据库类型改为 PostgreSQL 2025-12-31 17:29:46 +08:00
a510a4afcb fix(invite): 重命名字段并补充AGENT类型支持 2025-12-29 18:43:09 +08:00
c38f62c3c1 feat(theme): 新增主题列表Redis缓存机制
为提升查询性能,在KeyboardThemesServiceImpl中集成RedisTemplate,优先从缓存读取主题列表;新增ThemeCacheInitializer用于应用启动时预热缓存。
2025-12-29 15:13:50 +08:00
be921e144f feat(invite): 支持租户与用户两种邀请码类型 2025-12-29 15:04:30 +08:00
778cf4a0cb fix(entity): 补全用户邀请绑定台账字段与注释
为 KeyboardUserInvites 实体新增 clickToken、inviteType、profitTenantId、profitEmployeeId、inviterTenantId、inviteCode 等字段,并统一 Schema 注解空格格式,满足邀请链接归因、代理结算及审计需求。
2025-12-29 13:59:02 +08:00
fb0c0c34a9 refactor(model): 移除 click_token 字段并调整字段顺序
- 删除实体与 Mapper 中 click_token 相关定义
- 保持其余字段(bind_type、bound_at、bind_ip、bind_user_agent)顺序一致
2025-12-25 14:17:09 +08:00
6ef1488e5f feat(invite): 新增H5邀请链接配置与返回
在 AppConfig 中增加 inviteConfig 及 h5Link 字段;
服务层改造 getUserInviteCode 返回 InviteCodeRespVO 并填充 h5Link;
Controller 简化调用逻辑,统一走服务层组装 VO。
2025-12-24 22:02:08 +08:00
b9197c4275 feat(invite): 新增用户邀请码创建与查询接口 2025-12-24 21:36:27 +08:00
e90078791c refactor(service): 优化推荐逻辑不再排除已购买主题 2025-12-23 15:43:27 +08:00
1b7374e959 fix(controller): 将 themeId 参数改为可选 2025-12-23 14:09:26 +08:00
2b0fa71c40 [Claude Code] After prompt #0 2025-12-23 13:53:53 +08:00
619c59d786 [Claude Code] After prompt #8 2025-12-22 22:00:16 +08:00
d07478eaea chore(build): 移除 Maven Wrapper 脚本
删除 mvnw 与 mvnw.cmd,项目已改用全局 Maven 构建,避免维护冗余脚本。
2025-12-22 19:50:33 +08:00
c70a1bd0e2 feat(wallet): 新增分页DTO并统一交易记录接口入参 2025-12-22 19:30:20 +08:00
45d6058b90 fix(service): 修复查询用户主题时调用错误服务
将 KeyboardThemePurchaseService 改为 userThemesService,并调整查询条件从支付状态改为删除标记,确保获取正确的用户主题列表。
2025-12-22 18:31:04 +08:00
ecce22384b fix(service): 修复查询用户主题时调用错误服务
将 KeyboardThemePurchaseService 改为 userThemesService,并调整查询条件从支付状态改为删除标记,确保获取正确的用户主题列表。
2025-12-22 16:39:01 +08:00
44f031c939 chore(config): 将配置文件注释乱码替换为中文可读文本 2025-12-19 21:56:41 +08:00
f69393b79d fix(config): 切换模型至 text-embedding-v4 并更新 API 配置 2025-12-19 21:37:54 +08:00
b068ab4d7c refactor(invite): 移除用户主动生成邀请码功能
- 删除 InviteCodeRespVO.java VO 类
- 移除 KeyboardUserInviteCodesService 及其实现中的 createInviteCode/getUserInviteCode 方法
- 删除 UserController 中 /inviteCode 查询接口
- 注册流程不再自动为用户创建邀请码,仅保留绑定逻辑
2025-12-19 15:15:53 +08:00
6638ff2ccc feat(invite): 添加邀请码注册与验证功能
- 新增邀请码实体、Mapper、Service 及 XML 配置
- 注册接口支持填写邀请码并建立绑定关系
- 邀请码校验包含存在性、状态、过期及次数限制
- 补充相关错误码:INVITE_CODE_* 与 RECEIPT_ALREADY_PROCESSED
2025-12-19 14:47:54 +08:00
0ef7a7fd83 feat(invite): 添加邀请码注册与验证功能
- 新增邀请码实体、Mapper、Service 及 XML 配置
- 注册接口支持填写邀请码并建立绑定关系
- 邀请码校验包含存在性、状态、过期及次数限制
- 补充相关错误码:INVITE_CODE_* 与 RECEIPT_ALREADY_PROCESSED
2025-12-19 14:21:02 +08:00
419878a607 feat(invite): 新增用户邀请码功能
新增实体、Mapper、Service及Controller接口,支持注册时自动生成与用户查询个人邀请码
2025-12-18 19:20:25 +08:00
c4d0c60ea8 chore(core): 清理Demo代码并优化配置文件
删除DemoController和PostReviewStatusEnum等测试/废弃代码;
.gitignore、SaTokenConfigure、SendMailUtils、application.yml小幅更新;
AppleAppStoreConfig改用流式读取私钥,适配容器化部署。
2025-12-18 15:51:46 +08:00
95fb77a575 fix(chat): 保存LLM响应的生成ID用于链路追踪 2025-12-17 19:23:21 +08:00
abfac871fd feat(user): 新增用户反馈提交功能 2025-12-17 18:20:05 +08:00
198650556f feat(themes): 新增主题模糊搜索接口及鉴权放行
支持按名称模糊搜索主题,并标记用户已购状态;同步放开 /themes/search 无需登录访问
2025-12-17 16:57:39 +08:00
4666180b73 style(service): 添加详细注释并优化人设列表查询逻辑 2025-12-17 16:49:10 +08:00
35c45abf73 feat(service): 为标签人设列表增加Redis缓存 2025-12-17 16:46:50 +08:00
65cd9d9fae feat(service): 为所有人设列表添加Redis缓存
在 selectListWithRank 中先读缓存,未命中再查库并写入7天过期缓存,减少数据库压力。
2025-12-17 16:32:05 +08:00
0156156440 feat(character): 添加Redis缓存支持人设查询 2025-12-17 16:25:05 +08:00
27a8911b7f refactor(service): 优化聊天服务代码注释与结构
添加详细中文注释,明确用户配额校验、VIP权限判断及流式响应处理逻辑,提升可维护性。
2025-12-17 16:05:14 +08:00
323baa876f feat(chat): 新增免费额度与VIP校验逻辑
在聊天接口中增加用户免费次数及VIP身份校验,未通过时返回新错误码50022;
调用成功后若使用免费额度则自动扣减,保障额度体系闭环。
2025-12-17 15:51:46 +08:00
2621321dea refactor(chat): 拆分聊天逻辑至独立 ChatService 并提取 LLM 配置
将 ChatController 中的聊天与向量搜索流程整体迁移到 ChatServiceImpl,
新增 AppConfig.LLmConfig 集中管理系统提示语与最大消息长度,
消除控制器层复杂逻辑,提升可维护性与配置动态化能力。
2025-12-17 15:36:57 +08:00
86738e3d1b feat(chat): 新增聊天调用日志与动态配置支持
- 新增 KeyboardUserCallLog 实体及对应 Mapper、Service,用于记录每次聊天请求的模型、token、耗时、错误码等
- ChatController.talk() 在流式输出前后采集元数据,异步落库,支持错误码记录
- AppConfig 新增 QdrantConfig,支持 vectorSearchLimit 动态配置
- QdrantVectorService 改为从 Nacos 动态读取搜索条数,替代硬编码 limit=1
- UserController 登出时先清除用户会话再清除 token,避免并发异常
2025-12-17 15:03:23 +08:00
a237bc2987 refactor(config): 合并用户注册配置并调整默认配额
将 UserRegisterProperties 内嵌到 AppConfig,删除独立配置类;
freeTrialQuota 由 5 改为 3,新增 rewardBalance 字段;
同步更新 UserServiceImpl 初始化逻辑及 yml 配置。
2025-12-17 13:24:38 +08:00
8e26488738 feat(config): 接入 Nacos 配置中心
- 新增 AppConfig、NacosAppConfigCenter 动态配置类
- 将 userRegisterProperties 的默认值改为运行时从 Nacos 读取
- 注册/创建用户时免费配额改为动态配置获取
- 增加 nacos-client 依赖并配置 dev 环境连接信息
2025-12-16 21:50:00 +08:00
f95762138b feat(quota): 新增用户额度总计模块
增加用户免费体验额度配置,支持新用户注册时的额度分配功能
2025-12-16 17:54:53 +08:00
495485cc07 feat(quota): 新增用户额度总计模块
增加用户免费体验额度配置,支持新用户注册时的额度分配功能
2025-12-16 16:59:56 +08:00
cd6eca9cbb feat(apple): 支持App Store Server V2通知全类型处理
- 新增订阅、退款、偏好变更、消费请求等通知处理器
- 统一使用ResponseBodyV2DecodedPayload验签与分发
- 移除控制器层JWT解析逻辑,下沉至服务层
- 增加幂等、状态回滚及权益撤销/恢复能力
2025-12-16 15:50:35 +08:00
c54c14de58 refactor(service): 改用JWS验签,移除旧收据解析
废弃ReceiptUtility与AppStoreServerAPIClient,直接以SignedDataVerifier校验客户端传来的signedPayload(JWS),简化流程并减少一次网络IO。
2025-12-16 15:06:41 +08:00
c305dfaae4 fix(apple): 增加无效收据原因日志并补充订阅过期时间调试输出 2025-12-15 21:21:47 +08:00
0ad9de1011 fix(controller): 使用官方 SDK 验证 Apple 交易签名并解析 payload 2025-12-15 18:22:11 +08:00
d9a778f5aa refactor(apple-purchase): 重构苹果购买服务,增强可读性和健壮性 2025-12-15 15:15:10 +08:00
a70c1f4049 feat(apple): 新增服务器通知续订与JWT解析能力
- 支持解析Apple签名JWT并提取交易信息
- 新增processRenewNotification处理续订通知
- 添加测试用JWT生成、解析及发送重试记录示例
- 移除废弃ApplePayUtil,统一走新验证逻辑
2025-12-15 14:56:38 +08:00
c1dd4faf0e feat(apple): 新增苹果订阅通知接口并补充测试
- AppleReceiptController 新增 /apple/notification 端点,用于接收苹果服务器通知
- 调整路径前缀为 /apple,开放 /apple/receipt 与 /apple/notification 免登录
2025-12-12 20:37:43 +08:00
a24a795887 feat(purchase): 新增 Apple 内购完整链路
- AppleReceiptController 改造:验签后立刻落库并解锁权益
- 新增 ApplePurchaseService 处理业务:防重、写订单、发道具
- 新增 KeyboardUserPurchaseRecords 实体与 Mapper,记录用户购买
- ErrorCode 补充 RECEIPT_INVALID(50016)
- 删除过期 AGENTS.md,修正 i18n_message 表名与 CORS 白名单
2025-12-12 18:18:55 +08:00
2e16183cb8 feat(product): 新增键盘商品管理模块
新增商品实体、Mapper、Service、Controller 及 VO,支持商品列表、详情、订阅等接口;同步更新 Sa-Token 放行路径与 .gitignore
2025-12-12 14:15:30 +08:00
b4c35b0df3 fix(service): 优化向量搜索超时与中断处理
新增 ListenableFuture 超时保护(10s),捕获中断与超时异常并恢复中断状态,提升高并发鲁棒性。
2025-12-12 13:24:03 +08:00
172 changed files with 9009 additions and 1180 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

457
.claude/CLAUDE.md Normal file
View File

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

14
.gitignore vendored
View File

@@ -33,3 +33,17 @@ build/
### VS Code ###
.vscode/
/CLAUDE.md
/AGENTS.md
/src/test/
/.claude/agents/backend-architect.md
/.dockerignore
/Dockerfile
/.claude/ralph-loop.local.md
/deepgramAPI.md
/elevenLabs-websocketAPI.md
/elevenlabsAPI.md
/Getting Started with Flux.md
/voice-optimization-plan.md
/docs/websocket-api.md
/src/main/resources/static/ws-test.html
/.omc/

View File

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

View File

@@ -1,33 +0,0 @@
# Repository Guidelines
## Project Structure & Module Organization
- Entrypoint `src/main/java/com/yolo/keyborad/MyApplication.java`; feature code organized by layer: `controller` (REST), `service` (business), `mapper` (MyBatis mappers), `model`/`common`/`constant` for DTOs, responses, and constants, plus `config`, `aop`, `annotation`, `Interceptor`, and `utils` for cross-cutting concerns.
- Resource configs live in `src/main/resources`: `application.yml` with `application-dev.yml`/`application-prod.yml` profiles, mapper XML files under `mapper/`, and platform keys/certs (Apple, mail, storage). Keep secrets out of commits.
- Tests belong in `src/test/java/com/yolo/keyborad/...` mirroring package names; add fixtures alongside tests when needed.
## Build, Test, and Development Commands
- `./mvnw clean install` — full build with tests; requires JDK 17.
- `./mvnw test` — run test suite only.
- `./mvnw spring-boot:run -Dspring-boot.run.profiles=dev` — start the API with the dev profile (loads `application-dev.yml`).
- `./mvnw clean package -DskipTests` — create an artifact when tests are already covered elsewhere.
## Coding Style & Naming Conventions
- Java 17, Spring Boot 3.5, MyBatis/MyBatis-Plus; prefer Lombok for boilerplate (`@Data`, `@Builder`) and constructor injection for services.
- Use 4-space indentation, lowercase package names, `UpperCamelCase` for classes, `lowerCamelCase` for fields/params.
- Controllers end with `*Controller`, services with `*Service`, mapper interfaces with `*Mapper`, and request/response DTOs under `model` or `common` with clear suffixes like `Request`/`Response`.
- Keep configuration isolated in `config`; shared constants in `constant`; AOP/logging in `aop`; custom annotations in `annotation`.
## Testing Guidelines
- Use Spring Boot Test + JUnit (via `spring-boot-starter-test`, JUnit 4/5 support) and MockMvc/WebTestClient for HTTP layers when practical.
- Name classes `*Test` and align packages with the code under test. Cover service logic, mappers, and controller contracts (status + payload shape).
- For data-access tests, use in-memory setups or dedicated test containers and clean up test data.
## Commit & Pull Request Guidelines
- Follow the existing conventional style seen in history (e.g., `feat(user): add email registration`); keep scope lowercase and concise.
- PRs should describe the change, list validation steps/commands run, call out config/profile impacts, and link issues/tasks. Add screenshots or sample requests/responses for API-facing changes when helpful.
- Ensure secrets (p8 certificates, Mailgun keys, AWS creds) are never committed; rely on environment variables or local config overrides.
## Security & Configuration Tips
- Activate the intended profile via `SPRING_PROFILES_ACTIVE` or `-Dspring-boot.run.profiles`. Keep `application-dev.yml` local-only; never hardcode production endpoints or credentials.
- Validate signing/encryption helpers (`SignInterceptor`, JWT, Apple receipt validation) with representative non-production keys before merging.
- Log only necessary context; avoid logging tokens, receipts, or PII.

141
README.md
View File

@@ -1,26 +1,123 @@
# SpringBoot 项目初始模板
# Keyborad Backend
> Java SpringBoot 项目初始模板,整合了常用框架和示例代码,大家可以在此基础上快速开发自己的项目
基于 Spring Boot 3.5.5 的后端服务,集成了 AI 能力、向量搜索、Apple 登录等功能
## 模板功能
## 技术栈
- Spring Boot 2.7.0(贼新)
- Spring MVC
- MySQL 驱动
- MyBatis
- MyBatis Plus
- Spring Session Redis 分布式登录
- Spring AOP
- Apache Commons Lang3 工具类
- Lombok 注解
- Swagger + Knife4j 接口文档
- Spring Boot 调试工具和项目处理器
- 全局请求响应拦截器(记录日志)
- 全局异常处理器
- 自定义错误码
- 封装通用响应类
- 示例用户注册、登录、搜索功能
- 示例单元测试类
- 示例 SQL用户表
- **Java 17** + **Spring Boot 3.5.5**
- **Spring AI** - LLM 对话和文本嵌入OpenAI 兼容 API
- **Qdrant** - 向量数据库,支持语义搜索
- **PostgreSQL** - 关系型数据库
- **MyBatis Plus** - ORM 框架
- **Redis** - 会话存储和缓存
- **Sa-Token** - 认证授权框架
- **Knife4j** - API 文档
- **X-File-Storage** - 文件上传Cloudflare R2
- **MailerSend** - 邮件服务
访问 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

316
mvnw vendored
View File

@@ -1,316 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Maven Start Up Batch script
#
# Required ENV vars:
# ------------------
# JAVA_HOME - location of a JDK home dir
#
# Optional ENV vars
# -----------------
# M2_HOME - location of maven2's installed home dir
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
# e.g. to debug Maven itself, use
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
# ----------------------------------------------------------------------------
if [ -z "$MAVEN_SKIP_RC" ] ; then
if [ -f /usr/local/etc/mavenrc ] ; then
. /usr/local/etc/mavenrc
fi
if [ -f /etc/mavenrc ] ; then
. /etc/mavenrc
fi
if [ -f "$HOME/.mavenrc" ] ; then
. "$HOME/.mavenrc"
fi
fi
# OS specific support. $var _must_ be set to either true or false.
cygwin=false;
darwin=false;
mingw=false
case "`uname`" in
CYGWIN*) cygwin=true ;;
MINGW*) mingw=true;;
Darwin*) darwin=true
# Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
if [ -z "$JAVA_HOME" ]; then
if [ -x "/usr/libexec/java_home" ]; then
export JAVA_HOME="`/usr/libexec/java_home`"
else
export JAVA_HOME="/Library/Java/Home"
fi
fi
;;
esac
if [ -z "$JAVA_HOME" ] ; then
if [ -r /etc/gentoo-release ] ; then
JAVA_HOME=`java-config --jre-home`
fi
fi
if [ -z "$M2_HOME" ] ; then
## resolve links - $0 may be a link to maven's home
PRG="$0"
# need this for relative symlinks
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG="`dirname "$PRG"`/$link"
fi
done
saveddir=`pwd`
M2_HOME=`dirname "$PRG"`/..
# make it fully qualified
M2_HOME=`cd "$M2_HOME" && pwd`
cd "$saveddir"
# echo Using m2 at $M2_HOME
fi
# For Cygwin, ensure paths are in UNIX format before anything is touched
if $cygwin ; then
[ -n "$M2_HOME" ] &&
M2_HOME=`cygpath --unix "$M2_HOME"`
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
[ -n "$CLASSPATH" ] &&
CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
fi
# For Mingw, ensure paths are in UNIX format before anything is touched
if $mingw ; then
[ -n "$M2_HOME" ] &&
M2_HOME="`(cd "$M2_HOME"; pwd)`"
[ -n "$JAVA_HOME" ] &&
JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
fi
if [ -z "$JAVA_HOME" ]; then
javaExecutable="`which javac`"
if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
# readlink(1) is not available as standard on Solaris 10.
readLink=`which readlink`
if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
if $darwin ; then
javaHome="`dirname \"$javaExecutable\"`"
javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
else
javaExecutable="`readlink -f \"$javaExecutable\"`"
fi
javaHome="`dirname \"$javaExecutable\"`"
javaHome=`expr "$javaHome" : '\(.*\)/bin'`
JAVA_HOME="$javaHome"
export JAVA_HOME
fi
fi
fi
if [ -z "$JAVACMD" ] ; then
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
else
JAVACMD="`\\unset -f command; \\command -v java`"
fi
fi
if [ ! -x "$JAVACMD" ] ; then
echo "Error: JAVA_HOME is not defined correctly." >&2
echo " We cannot execute $JAVACMD" >&2
exit 1
fi
if [ -z "$JAVA_HOME" ] ; then
echo "Warning: JAVA_HOME environment variable is not set."
fi
CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
# traverses directory structure from process work directory to filesystem root
# first directory with .mvn subdirectory is considered project base directory
find_maven_basedir() {
if [ -z "$1" ]
then
echo "Path not specified to find_maven_basedir"
return 1
fi
basedir="$1"
wdir="$1"
while [ "$wdir" != '/' ] ; do
if [ -d "$wdir"/.mvn ] ; then
basedir=$wdir
break
fi
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
if [ -d "${wdir}" ]; then
wdir=`cd "$wdir/.."; pwd`
fi
# end of workaround
done
echo "${basedir}"
}
# concatenates all lines of a file
concat_lines() {
if [ -f "$1" ]; then
echo "$(tr -s '\n' ' ' < "$1")"
fi
}
BASE_DIR=`find_maven_basedir "$(pwd)"`
if [ -z "$BASE_DIR" ]; then
exit 1;
fi
##########################################################################################
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
# This allows using the maven wrapper in projects that prohibit checking in binary data.
##########################################################################################
if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found .mvn/wrapper/maven-wrapper.jar"
fi
else
if [ "$MVNW_VERBOSE" = true ]; then
echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
fi
if [ -n "$MVNW_REPOURL" ]; then
jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
else
jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
fi
while IFS="=" read key value; do
case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
esac
done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
if [ "$MVNW_VERBOSE" = true ]; then
echo "Downloading from: $jarUrl"
fi
wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
if $cygwin; then
wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"`
fi
if command -v wget > /dev/null; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found wget ... using wget"
fi
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
else
wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
fi
elif command -v curl > /dev/null; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found curl ... using curl"
fi
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
curl -o "$wrapperJarPath" "$jarUrl" -f
else
curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f
fi
else
if [ "$MVNW_VERBOSE" = true ]; then
echo "Falling back to using Java to download"
fi
javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
# For Cygwin, switch paths to Windows format before running javac
if $cygwin; then
javaClass=`cygpath --path --windows "$javaClass"`
fi
if [ -e "$javaClass" ]; then
if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
if [ "$MVNW_VERBOSE" = true ]; then
echo " - Compiling MavenWrapperDownloader.java ..."
fi
# Compiling the Java class
("$JAVA_HOME/bin/javac" "$javaClass")
fi
if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
# Running the downloader
if [ "$MVNW_VERBOSE" = true ]; then
echo " - Running MavenWrapperDownloader.java ..."
fi
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
fi
fi
fi
fi
##########################################################################################
# End of extension
##########################################################################################
export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
if [ "$MVNW_VERBOSE" = true ]; then
echo $MAVEN_PROJECTBASEDIR
fi
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
# For Cygwin, switch paths to Windows format before running java
if $cygwin; then
[ -n "$M2_HOME" ] &&
M2_HOME=`cygpath --path --windows "$M2_HOME"`
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
[ -n "$CLASSPATH" ] &&
CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
fi
# Provide a "standardized" way to retrieve the CLI args that will
# work with both Windows and non-Windows executions.
MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
export MAVEN_CMD_LINE_ARGS
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
exec "$JAVACMD" \
$MAVEN_OPTS \
$MAVEN_DEBUG_OPTS \
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
"-Dmaven.home=${M2_HOME}" \
"-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"

188
mvnw.cmd vendored
View File

@@ -1,188 +0,0 @@
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM https://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Maven Start Up Batch script
@REM
@REM Required ENV vars:
@REM JAVA_HOME - location of a JDK home dir
@REM
@REM Optional ENV vars
@REM M2_HOME - location of maven2's installed home dir
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
@REM e.g. to debug Maven itself, use
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
@REM ----------------------------------------------------------------------------
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
@echo off
@REM set title of command window
title %0
@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
@REM set %HOME% to equivalent of $HOME
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
@REM Execute a user defined script before this one
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
:skipRcPre
@setlocal
set ERROR_CODE=0
@REM To isolate internal variables from possible post scripts, we use another setlocal
@setlocal
@REM ==== START VALIDATION ====
if not "%JAVA_HOME%" == "" goto OkJHome
echo.
echo Error: JAVA_HOME not found in your environment. >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
:OkJHome
if exist "%JAVA_HOME%\bin\java.exe" goto init
echo.
echo Error: JAVA_HOME is set to an invalid directory. >&2
echo JAVA_HOME = "%JAVA_HOME%" >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
@REM ==== END VALIDATION ====
:init
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
@REM Fallback to current working directory if not found.
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
set EXEC_DIR=%CD%
set WDIR=%EXEC_DIR%
:findBaseDir
IF EXIST "%WDIR%"\.mvn goto baseDirFound
cd ..
IF "%WDIR%"=="%CD%" goto baseDirNotFound
set WDIR=%CD%
goto findBaseDir
:baseDirFound
set MAVEN_PROJECTBASEDIR=%WDIR%
cd "%EXEC_DIR%"
goto endDetectBaseDir
:baseDirNotFound
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
cd "%EXEC_DIR%"
:endDetectBaseDir
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
@setlocal EnableExtensions EnableDelayedExpansion
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
:endReadAdditionalConfig
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
)
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
if exist %WRAPPER_JAR% (
if "%MVNW_VERBOSE%" == "true" (
echo Found %WRAPPER_JAR%
)
) else (
if not "%MVNW_REPOURL%" == "" (
SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
)
if "%MVNW_VERBOSE%" == "true" (
echo Couldn't find %WRAPPER_JAR%, downloading it ...
echo Downloading from: %DOWNLOAD_URL%
)
powershell -Command "&{"^
"$webclient = new-object System.Net.WebClient;"^
"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
"}"^
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^
"}"
if "%MVNW_VERBOSE%" == "true" (
echo Finished downloading %WRAPPER_JAR%
)
)
@REM End of extension
@REM Provide a "standardized" way to retrieve the CLI args that will
@REM work with both Windows and non-Windows executions.
set MAVEN_CMD_LINE_ARGS=%*
%MAVEN_JAVA_EXE% ^
%JVM_CONFIG_MAVEN_PROPS% ^
%MAVEN_OPTS% ^
%MAVEN_DEBUG_OPTS% ^
-classpath %WRAPPER_JAR% ^
"-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
%WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
if ERRORLEVEL 1 goto error
goto end
:error
set ERROR_CODE=1
:end
@endlocal & set ERROR_CODE=%ERROR_CODE%
if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
@REM check for post script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
:skipRcPost
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
if "%MAVEN_BATCH_PAUSE%"=="on" pause
if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
cmd /C exit /B %ERROR_CODE%

30
pom.xml
View File

@@ -55,6 +55,13 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-client</artifactId>
<version>3.1.1</version>
</dependency>
<!-- qdrant向量数据库 sdk -->
<dependency>
<groupId>io.qdrant</groupId>
@@ -101,7 +108,7 @@
<dependency>
<groupId>com.apple.itunes.storekit</groupId>
<artifactId>app-store-server-library</artifactId>
<version>3.6.0</version>
<version>4.0.0</version>
</dependency>
<!-- x-file-storage -->
@@ -273,6 +280,27 @@
<build>
<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>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>

BIN
src/main/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,4 +1,4 @@
package com.yolo.keyborad.Interceptor;
package com.yolo.keyborad.interceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yolo.keyborad.utils.SignUtils;

View File

@@ -28,6 +28,12 @@ public enum ErrorCode {
CHAT_CHARACTER_NOT_FOUND(40008, "键盘人设不存在"),
CHAT_MESSAGE_TOO_LONG(40009, "聊天消息过长最大支持1000字符"),
CHAT_SAVE_DATA_EMPTY(40010, "保存数据不能为空"),
COMPANION_MESSAGE_EMPTY(40011, "消息内容不能为空"),
COMPANION_ID_EMPTY(40012, "AI陪聊角色ID不能为空"),
COMPANION_NOT_FOUND(40019, "AI陪聊角色不存在"),
COMMENT_CONTENT_EMPTY(40013, "评论内容不能为空"),
COMMENT_NOT_FOUND(40014, "评论不存在"),
COMMENT_ID_EMPTY(40015, "评论ID不能为空"),
TOKEN_NOT_FOUND(40102, "未能读取到有效用户令牌"),
TOKEN_INVALID(40103, "令牌无效"),
TOKEN_TIMEOUT(40104, "令牌已过期"),
@@ -50,7 +56,30 @@ public enum ErrorCode {
INSUFFICIENT_BALANCE(50013, "余额不足"),
THEME_NOT_FOUND(40410, "主题不存在"),
THEME_ALREADY_PURCHASED(50014, "主题已购买"),
THEME_NOT_AVAILABLE(50015, "主题不可购买");
THEME_NOT_AVAILABLE(50015, "主题不可购买"),
RECEIPT_INVALID(50016, "收据无效"),
UPDATE_USER_VIP_STATUS_ERROR(50017, "更新用户VIP状态失败"),
PRODUCT_QUOTA_NOT_SET(50018, "商品额度未配置"),
LACK_ORIGIN_TRANSACTION_ID_ERROR(50019, "缺少原始交易id"),
UNKNOWN_PRODUCT_TYPE(50020, "未知商品类型"),
PRODUCT_NOT_FOUND(50021, "商品不存在"),
NO_QUOTA_AND_NOT_VIP(50022, "免费次数已用完请开通VIP"),
INVITE_CODE_NOT_FOUND(50023, "邀请码不存在"),
INVITE_CODE_INVALID(50024, "邀请码无效"),
INVITE_CODE_EXPIRED(50025, "邀请码已过期"),
INVITE_CODE_USED_UP(50026, "邀请码使用次数已达上限"),
INVITE_CODE_ALREADY_BOUND(50028, "您已绑定过邀请码,无法重复绑定"),
INVITE_CODE_CANNOT_BIND_SELF(50029, "不能绑定自己的邀请码"),
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, "举报类型不能为空");
/**
* 状态码
*/

View File

@@ -0,0 +1,60 @@
package com.yolo.keyborad.config;
import lombok.Data;
import java.math.BigDecimal;
/*
* @author: ziin
* @date: 2025/12/16 21:18
*/
@Data
public class AppConfig {
private UserRegisterProperties userRegisterProperties = new UserRegisterProperties();
private QdrantConfig qdrantConfig = new QdrantConfig();
private LLmConfig llmConfig = new LLmConfig();
private inviteConfig inviteConfig = new inviteConfig();
@Data
public static class UserRegisterProperties {
//新用户注册时的免费使用次数
private Integer freeTrialQuota = 3;
//Vip用户每天能免费聊天次数
private Integer vipFreeTrialTalk = 3;
//新用户注册时的奖励余额
private BigDecimal rewardBalance = BigDecimal.valueOf(0);
}
@Data
public static class QdrantConfig {
//向量搜索时的返回数量限制
private Integer vectorSearchLimit = 1;
}
@Data
public static class LLmConfig {
//LLM系统提示语
private String systemPrompt = """
Format rules:
- Return EXACTLY 3 replies.
- Use "<SPLIT>" as the separator.
- reply1<SPLIT>reply2<SPLIT>reply3
""";
//聊天消息最大长度
private Integer maxMessageLength = 1000;
}
@Data
public static class inviteConfig {
private String h5Link = "";
}
}

View File

@@ -49,8 +49,7 @@ public class AppleAppStoreConfig {
public AppStoreServerAPIClient appStoreServerAPIClient() throws Exception {
// 加载私钥文件
Resource keyResource = resourceLoader.getResource(properties.getPrivateKeyPath());
Path keyPath = keyResource.getFile().toPath();
String encodedKey = Files.readString(keyPath);
String encodedKey = new String(keyResource.getInputStream().readAllBytes());
// 获取环境配置(沙盒或生产)
Environment env = Environment.valueOf(properties.getEnvironment());

View File

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

View File

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

View File

@@ -12,6 +12,9 @@ import org.springframework.ai.retry.RetryUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.MultiValueMap;
import java.util.Map;
/*
@@ -32,6 +35,7 @@ public class LLMConfig {
public OpenAiApi openAiApi() {
return OpenAiApi.builder()
.apiKey(apiKey)
.headers(MultiValueMap.fromSingleValue(Map.of("X-Title", "key of love")))
.baseUrl(baseUrl)
.build();
}
@@ -53,7 +57,7 @@ public class LLMConfig {
this.openAiApi(),
MetadataMode.EMBED,
OpenAiEmbeddingOptions.builder()
.model("qwen/qwen3-embedding-4b")
.model("text-embedding-v4")
.dimensions(1536)
.user("user-6")
.build(),

View File

@@ -25,7 +25,7 @@ public class MyBatisPlusConfig {
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
return interceptor;
}
}

View File

@@ -0,0 +1,82 @@
package com.yolo.keyborad.config;
import com.alibaba.nacos.api.NacosFactory;
import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.config.listener.Listener;
import com.alibaba.nacos.api.exception.NacosException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;
@Slf4j
@Configuration
public class NacosAppConfigCenter {
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
@Bean
public ConfigService nacosConfigService(
@Value("${nacos.config.server-addr}") String serverAddr
) throws NacosException {
Properties p = new Properties();
p.put("serverAddr", serverAddr);
return NacosFactory.createConfigService(p);
}
@Bean
public DynamicAppConfig dynamicAppConfig(
ConfigService configService,
@Value("${nacos.config.group}") String group,
@Value("${nacos.config.data-id}") String dataId
) throws Exception {
DynamicAppConfig holder = new DynamicAppConfig();
// 启动先拉一次
String content = configService.getConfig(dataId, group, 3000);
if (content != null && !content.isBlank()) {
holder.ref.set(parse(content));
log.info("Loaded nacos config: dataId={}, group={}", dataId, group);
} else {
log.warn("Empty nacos config: dataId={}, group={}", dataId, group);
}
// 监听热更新
configService.addListener(dataId, group, new Listener() {
@Override public Executor getExecutor() { return null; }
@Override public void receiveConfigInfo(String configInfo) {
try {
AppConfig newCfg = parse(configInfo);
holder.ref.set(newCfg);
log.info("Refreshed nacos config: dataId={}, group={}", dataId, group);
log.info("New config: {}", newCfg.toString());
} catch (Exception e) {
// 解析失败不覆盖旧配置
log.error("Failed to refresh nacos config: dataId={}, keep old config.", dataId, e);
}
}
});
return holder;
}
private AppConfig parse(String yaml) throws Exception {
if (yaml == null || yaml.isBlank()) return new AppConfig();
return yamlMapper.readValue(yaml, AppConfig.class);
}
@Getter
public static class DynamicAppConfig {
private final AtomicReference<AppConfig> ref = new AtomicReference<>(new AppConfig());
}
}

View File

@@ -24,7 +24,7 @@ public class RedisConfig {
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(connectionFactory);
// 设置key序列化方式
template.setKeySerializer(new StringRedisSerializer());
// 设置value序列化方式
@@ -33,7 +33,30 @@ public class RedisConfig {
template.setHashKeySerializer(new StringRedisSerializer());
// 设置hash value序列化方式
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
/**
* 配置对象序列化的RedisTemplate
* @param connectionFactory Redis连接工厂
* @return RedisTemplate实例
*/
@Bean("objectRedisTemplate")
public org.springframework.data.redis.core.RedisTemplate<String, Object> objectRedisTemplate(RedisConnectionFactory connectionFactory) {
org.springframework.data.redis.core.RedisTemplate<String, Object> template = new org.springframework.data.redis.core.RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 设置key序列化方式
template.setKeySerializer(new StringRedisSerializer());
// 设置value序列化方式使用JSON序列化
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// 设置hash key序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
// 设置hash value序列化方式
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}

View File

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

View File

@@ -5,7 +5,7 @@ import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -64,7 +64,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/demo/embed",
"/demo/testSaveEmbed",
"/demo/testSearch",
"/demo/tsetSearchText",
"/demo/testSearchText",
"/file/upload",
"/user/logout",
"/tag/list",
@@ -73,15 +73,16 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/character/listByUser",
"/user/detail",
"/user/register",
"/user/updateInfo",
"/character/updateUserCharacterSort",
"/character/delUserCharacter",
"/user/sendVerifyMail",
"/user/verifyMailCode",
"/character/listWithNotLogin",
"/character/listByTagWithNotLogin",
"/character/listByTag",
"/character/detailWithNotLogin",
"/character/addUserCharacter",
"/api/apple/validate-receipt",
"/character/list",
"/user/resetPassWord",
"/chat/talk",
@@ -93,7 +94,30 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/themes/purchase/list",
"/themes/detail",
"/themes/recommended",
"/user-themes/batch-delete"
"/themes/search",
"/user-themes/batch-delete",
"/products/listByType",
"/products/detail",
"/products/inApp/list",
"/products/subscription/list",
"/purchase/handle",
"/apple/notification",
"/apple/receipt",
"/apple/validate-receipt",
"/user/inviteCode",
"/user/bindInviteCode",
"/themes/listAllStyles",
"/wallet/transactions",
"/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

View File

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

View File

@@ -0,0 +1,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);
}
}

View File

@@ -1,27 +1,88 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
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.AppleReceiptValidationResult;
import com.yolo.keyborad.service.ApplePurchaseService;
import com.yolo.keyborad.service.AppleReceiptService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
@RestController
@RequestMapping("/api/apple")
@RequestMapping("/apple")
@Slf4j
public class AppleReceiptController {
private final AppleReceiptService appleReceiptService;
private final ApplePurchaseService applePurchaseService;
public AppleReceiptController(AppleReceiptService appleReceiptService) {
public AppleReceiptController(AppleReceiptService appleReceiptService,
ApplePurchaseService applePurchaseService) {
this.appleReceiptService = appleReceiptService;
this.applePurchaseService = applePurchaseService;
}
@PostMapping("/receipt")
public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) {
if (body == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
String receipt = body.get("receipt");
if (receipt == null || receipt.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "receipt 不能为空");
}
return appleReceiptService.validateReceipt(receipt);
}
@PostMapping("/validate-receipt")
public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) {
String receipt = body.get("receipt");
return appleReceiptService.validateReceipt(receipt);
public BaseResponse<Boolean> handlePurchase(@RequestBody Map<String, String> body) {
if (body == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
String signedPayload = body.get("signedPayload");
if (signedPayload == null || signedPayload.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
}
Long userId = StpUtil.getLoginIdAsLong();
AppleReceiptValidationResult validationResult = appleReceiptService.validateReceipt(signedPayload);
applePurchaseService.processPurchase(userId, validationResult);
return ResultUtils.success(Boolean.TRUE);
}
/**
* 接收 Apple 服务器通知
* 处理来自 Apple 的服务器到服务器通知,包括订阅续订、退款等事件
* 所有验证和处理逻辑都委托给 service 层
*
* @param body 请求体,包含 signedPayload 字段
* @return 处理结果
* @throws BusinessException 当 signedPayload 为空时抛出
*/
@PostMapping("/notification")
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body) {
// 参数校验
if (body == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
String signedPayload = body.get("signedPayload");
if (signedPayload == null || signedPayload.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
}
// 委托给 service 层处理所有通知逻辑
appleReceiptService.processNotification(signedPayload);
return ResultUtils.success(Boolean.TRUE);
}
}

View File

@@ -49,8 +49,7 @@ public class CharacterController {
@GetMapping("/detail")
@Operation(summary = "人设详情", description = "人设详情接口")
public BaseResponse<KeyboardCharacterRespVO> detail(@RequestParam("id") Long id) {
KeyboardCharacter character = characterService.getById(id);
return ResultUtils.success(BeanUtil.copyProperties(character, KeyboardCharacterRespVO.class));
return ResultUtils.success(characterService.getDetailById(id));
}
@GetMapping("/listByTag")

View File

@@ -3,33 +3,37 @@ package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.QdrantPayloadMapper;
import com.yolo.keyborad.model.dto.chat.ChatHistoryPageReq;
import com.yolo.keyborad.model.dto.chat.ChatMessageReq;
import com.yolo.keyborad.model.dto.chat.ChatReq;
import com.yolo.keyborad.model.dto.chat.ChatSaveReq;
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
import com.yolo.keyborad.model.entity.KeyboardCharacter;
import com.yolo.keyborad.service.KeyboardCharacterService;
import com.yolo.keyborad.model.dto.chat.SessionResetReq;
import com.yolo.keyborad.model.vo.AudioTaskVO;
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
import com.yolo.keyborad.model.vo.ChatMessageVO;
import com.yolo.keyborad.model.vo.ChatSessionVO;
import com.yolo.keyborad.model.vo.ChatVoiceVO;
import com.yolo.keyborad.service.ChatService;
import com.yolo.keyborad.service.KeyboardAiChatMessageService;
import com.yolo.keyborad.service.KeyboardAiChatSessionService;
import com.yolo.keyborad.service.impl.QdrantVectorService;
import io.qdrant.client.grpc.JsonWithInt;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/*
@@ -42,12 +46,6 @@ import java.util.Map;
@Tag(name = "聊天", description = "聊天接口")
public class ChatController {
// 最大消息长度限制
private static final int MAX_MESSAGE_LENGTH = 1000;
@Resource
private ChatClient client;
@Resource
private OpenAiEmbeddingModel embeddingModel;
@@ -55,109 +53,47 @@ public class ChatController {
private QdrantVectorService qdrantVectorService;
@Resource
private KeyboardCharacterService keyboardCharacterService;
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")
@Operation(summary = "聊天润色接口", description = "聊天润色接口")
public Flux<ServerSentEvent<ChatStreamMessage>> talk(@RequestBody ChatReq chatReq){
// 1. 参数校验
if (chatReq == null) {
log.error("聊天请求参数为空");
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
if (chatReq.getCharacterId() == null) {
log.error("键盘人设ID为空");
throw new BusinessException(ErrorCode.CHAT_CHARACTER_ID_EMPTY);
}
if (StrUtil.isBlank(chatReq.getMessage())) {
log.error("聊天消息为空");
throw new BusinessException(ErrorCode.CHAT_MESSAGE_EMPTY);
}
if (chatReq.getMessage().length() > MAX_MESSAGE_LENGTH) {
log.error("聊天消息过长,长度: {}", chatReq.getMessage().length());
throw new BusinessException(ErrorCode.CHAT_MESSAGE_TOO_LONG);
}
// 2. 验证键盘人设是否存在
KeyboardCharacter character = keyboardCharacterService.getById(chatReq.getCharacterId());
if (character == null) {
log.error("键盘人设不存在ID: {}", chatReq.getCharacterId());
throw new BusinessException(ErrorCode.CHAT_CHARACTER_NOT_FOUND);
}
// 3. LLM 流式输出
Flux<ChatStreamMessage> llmFlux = client
.prompt(character.getPrompt())
.system("""
Format rules:
- Return EXACTLY 3 replies.
- Use "<SPLIT>" as the separator.
- reply1<SPLIT>reply2<SPLIT>reply3
""")
.user(chatReq.getMessage())
.options(OpenAiChatOptions.builder()
.user(StpUtil.getLoginIdAsString())
.build())
.stream()
.content()
.concatMap(chunk -> {
// 拆成单字符
List<String> chars = chunk.codePoints()
.mapToObj(cp -> new String(Character.toChars(cp)))
.toList();
// 按 3 个字符批量发送
List<String> batched = new ArrayList<>();
StringBuilder sb = new StringBuilder();
for (String ch : chars) {
sb.append(ch);
if (sb.length() >= 3) {
batched.add(sb.toString());
sb.setLength(0);
}
}
if (!sb.isEmpty()) {
batched.add(sb.toString());
}
return Flux.fromIterable(batched)
.map(s -> new ChatStreamMessage("llm_chunk", s));
})
.doOnError(error -> log.error("LLM调用失败", error))
.onErrorResume(error ->
Flux.just(new ChatStreamMessage("error", "LLM服务暂时不可用请稍后重试"))
);
// 4. 向量搜索Flux一次性发送搜索结果
Flux<ChatStreamMessage> searchFlux = Mono
.fromCallable(() -> qdrantVectorService.searchText(chatReq.getMessage()))
.subscribeOn(Schedulers.boundedElastic()) // 避免阻塞 event-loop
.map(list -> new ChatStreamMessage("search_result", list))
.doOnError(error -> log.error("向量搜索失败", error))
.onErrorResume(error ->
Mono.just(new ChatStreamMessage("search_result", new ArrayList<>()))
)
.flux();
// 5. 结束标记
Flux<ChatStreamMessage> doneFlux =
Flux.just(new ChatStreamMessage("done", null));
// 6. 合并所有Flux
Flux<ChatStreamMessage> merged =
Flux.merge(llmFlux, searchFlux)
.concatWith(doneFlux);
// 7. SSE 包装
return merged.map(msg ->
ServerSentEvent.builder(msg)
.event(msg.getType())
.build()
);
return chatService.talk(chatReq);
}
@@ -188,4 +124,37 @@ public class ChatController {
log.info("聊天嵌入保存成功用户ID: {}, 文本长度: {}", chatSaveReq.getUserId(), chatSaveReq.getUserText().length());
return ResultUtils.success(true);
}
@PostMapping("/history")
@Operation(summary = "分页查询聊天记录", description = "分页查询用户与AI陪聊角色的聊天记录")
public BaseResponse<IPage<ChatMessageHistoryVO>> pageHistory(@RequestBody ChatHistoryPageReq req) {
if (req.getCompanionId() == null) {
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
}
Long userId = StpUtil.getLoginIdAsLong();
IPage<ChatMessageHistoryVO> result = aiChatMessageService.pageHistory(
userId, req.getCompanionId(), req.getPageNum(), req.getPageSize());
return ResultUtils.success(result);
}
@PostMapping("/session/reset")
@Operation(summary = "重置会话", description = "重置与AI角色的聊天会话将当前会话设为不活跃并创建新会话后续聊天记录将绑定到新会话")
public BaseResponse<ChatSessionVO> resetSession(@RequestBody SessionResetReq req) {
if (req.getCompanionId() == null) {
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
}
Long userId = StpUtil.getLoginIdAsLong();
var newSession = aiChatSessionService.resetSession(userId, req.getCompanionId());
ChatSessionVO vo = ChatSessionVO.builder()
.sessionId(newSession.getId())
.companionId(newSession.getCompanionId())
.resetVersion(newSession.getResetVersion())
.createdAt(newSession.getCreatedAt())
.build();
return ResultUtils.success(vo);
}
}

View File

@@ -1,125 +0,0 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.json.JSONUtil;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.model.dto.EmbedSaveReq;
import com.yolo.keyborad.model.dto.IosPayVerifyReq;
import com.yolo.keyborad.model.dto.SearchEmbedReq;
import com.yolo.keyborad.model.dto.TextSearchReq;
import com.yolo.keyborad.model.vo.QdrantSearchItem;
import com.yolo.keyborad.service.impl.QdrantVectorService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.embedding.Embedding;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.Arrays;
import java.util.List;
/*
* @author: ziin
* @date: 2025/10/28 20:42
*/
@RestController
@RequestMapping("/demo")
@Slf4j
@CrossOrigin
@Tag(name = "测试控制器", description = "测试控制器")
public class DemoController {
@Resource
private ChatClient client;
@Resource
private OpenAiEmbeddingModel embeddingModel;
@Resource
private QdrantVectorService qdrantVectorService;
@GetMapping("/test")
@Operation(summary = "测试接口", description = "测试接口")
public BaseResponse<String> testDemo(){
return ResultUtils.success("hello world");
}
@GetMapping("/talk")
@Operation(summary = "测试聊天接口", description = "测试接口")
@Parameter(name = "userInput",required = true,description = "测试聊天接口",example = "talk to something")
public Flux<String> testTalk(@DefaultValue("you are so cute!") String userInput){
return client
.prompt("""
You're a 25-year-old guy—witty and laid-back, always replying in English.
Read the user's last message, then write three short and funny replies that sound like something a guy would say.
Go easy on the emojis.
Keep each under 20 words.
User message: %s
""".formatted(userInput))
.system("""
Format rules (very important):
- Return EXACTLY 3 replies.
- Use "<SPLIT>" as the separator between replies.
- Output format: reply1<SPLIT>reply2<SPLIT>reply3
- Do NOT use "<SPLIT>" inside any reply.
""")
.user(userInput)
.options(OpenAiChatOptions.builder()
.user(StpUtil.getLoginIdAsString())// ✅ 这里每次请求都会重新取当前登录用户
.build())
.stream()
.content();
}
@PostMapping("/embed")
@Operation(summary = "测试向量接口", description = "测试向量接口")
@Parameter(name = "userInput",required = true,description = "测试向量接口",example = "you are so cute!")
public BaseResponse<Embedding> testEmbed(@DefaultValue("you are so cute!") @RequestBody List<String> userInput){
EmbeddingResponse response = embeddingModel.embedForResponse(userInput);
return ResultUtils.success(response.getResult());
}
// @PostMapping("/testSaveEmbed")
// @Operation(summary = "测试存储向量接口", description = "测试存储向量接口")
// @Parameter(name = "userInput",required = true,description = "测试存储向量接口")
// public BaseResponse<Boolean> testSaveEmbed(@RequestBody EmbedSaveReq embedSaveReq) {
// qdrantVectorService.upsertPoint(embedSaveReq.getRecordItem().getId()
// , embedSaveReq.getVector()
// , JSONUtil.toJsonStr(embedSaveReq.getRecordItem()));
// return ResultUtils.success(true);
// }
// @PostMapping("/testSearch")
// @Operation(summary = "测试搜索向量接口", description = "测试搜索向量接口")
// @Parameter(name = "userInput",required = true,description = "测试搜索向量接口")
// public BaseResponse<List<QdrantSearchItem>> testSearch(@RequestBody SearchEmbedReq searchEmbedReq) {
// return ResultUtils.success(qdrantVectorService.searchPoint(searchEmbedReq.getUserInputEmbed(), 3));
// }
@PostMapping("/tsetSearchText")
@Operation(summary = "测试搜索语义接口", description = "测试搜索语义接口")
@Parameter(name = "userInput",required = true,description = "测试搜索语义接口")
public BaseResponse<List<QdrantSearchItem>> testSearchText(@RequestBody TextSearchReq textSearchReq) {
return ResultUtils.success(qdrantVectorService.searchText(textSearchReq.getUserInput()));
}
}

View File

@@ -27,8 +27,7 @@ public class FileController {
@PostMapping("/upload")
@Operation(summary = "上传文件", description = "上传文件接口")
@Parameter(name = "file",required = true,description = "上传的文件")
public BaseResponse<String> upload(@RequestParam("file") MultipartFile file){
public BaseResponse<String> upload(@RequestPart("file") MultipartFile file){
String fileUrl = fileService.upload(file);
return ResultUtils.success(fileUrl);
}

View File

@@ -0,0 +1,71 @@
package com.yolo.keyborad.controller;
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.vo.products.KeyboardProductItemRespVO;
import com.yolo.keyborad.service.KeyboardProductItemsService;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/*
* @author: ziin
* @date: 2025/12/12
*/
@RestController
@Slf4j
@RequestMapping("/products")
@Tag(name = "商品", description = "商品相关接口")
public class ProductsController {
@Resource
private KeyboardProductItemsService productItemsService;
@GetMapping("/detail")
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情")
public BaseResponse<KeyboardProductItemRespVO> getProductDetail(
@RequestParam(value = "id", required = false) Long id,
@RequestParam(value = "productId", required = false) String productId
) {
if (id == null && (productId == null || productId.isBlank())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "id 或 productId 至少传一个");
}
KeyboardProductItemRespVO result = (id != null)
? productItemsService.getProductDetailById(id)
: productItemsService.getProductDetailByProductId(productId);
return ResultUtils.success(result);
}
@GetMapping("/listByType")
@Operation(summary = "按类型查询商品列表", description = "根据商品类型查询商品列表type=all 返回全部")
public BaseResponse<List<KeyboardProductItemRespVO>> listByType(@RequestParam("type") String type) {
if (type == null || type.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "type 不能为空");
}
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type);
return ResultUtils.success(result);
}
@GetMapping("/inApp/list")
@Operation(summary = "查询内购商品列表", description = "查询 type=in-app-purchase 的商品列表")
public BaseResponse<List<KeyboardProductItemRespVO>> listInAppPurchases() {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("in-app-purchase");
return ResultUtils.success(result);
}
@GetMapping("/subscription/list")
@Operation(summary = "查询订阅商品列表", description = "查询 type=subscription 的商品列表")
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions() {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription");
return ResultUtils.success(result);
}
}

View File

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

View File

@@ -85,10 +85,26 @@ public class ThemesController {
@GetMapping("/recommended")
@Operation(summary = "推荐主题列表", description = "按真实下载数量降序返回推荐主题")
public BaseResponse<List<KeyboardThemesRespVO>> getRecommendedThemes() {
public BaseResponse<List<KeyboardThemesRespVO>> getRecommendedThemes(@RequestParam(required = false) Long themeId) {
Long userId = StpUtil.getLoginIdAsLong();
List<KeyboardThemesRespVO> result = themesService.getRecommendedThemes(userId);
List<KeyboardThemesRespVO> result = themesService.getRecommendedThemes(userId, themeId);
return ResultUtils.success(result);
}
@GetMapping("/search")
@Operation(summary = "搜索主题", description = "根据主题名称模糊搜索主题")
public BaseResponse<List<KeyboardThemesRespVO>> searchThemes(@RequestParam String themeName) {
Long userId = StpUtil.getLoginIdAsLong();
List<KeyboardThemesRespVO> result = themesService.searchThemesByName(themeName, userId);
return ResultUtils.success(result);
}
@PostMapping("/restore")
@Operation(summary = "恢复已删除的主题", description = "将用户已删除的主题重新展示")
public BaseResponse<Void> restoreTheme(@RequestParam Long themeId) {
Long userId = StpUtil.getLoginIdAsLong();
themePurchaseService.restoreDeletedTheme(userId, themeId);
return ResultUtils.success(null);
}
}

View File

@@ -6,20 +6,22 @@ import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.model.dto.AppleLoginReq;
import com.yolo.keyborad.model.dto.user.*;
import com.yolo.keyborad.model.entity.KeyboardFeedback;
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.KeyboardUserRespVO;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.text.SimpleDateFormat;
@@ -42,6 +44,11 @@ public class UserController {
@Resource
private UserService userService;
@Resource
private KeyboardFeedbackService feedbackService;
@Resource
private KeyboardUserInviteCodesService inviteCodesService;
/**
* 苹果登录
*
@@ -57,6 +64,7 @@ public class UserController {
@GetMapping("/logout")
@Operation(summary = "退出登录", description = "退出登录接口")
public BaseResponse<Boolean> logout() {
StpUtil.logout(StpUtil.getLoginIdAsLong());
StpUtil.logoutByTokenValue(StpUtil.getTokenValue());
return ResultUtils.success(true);
}
@@ -94,8 +102,7 @@ public class UserController {
@PostMapping("/register")
@Operation(summary = "用户注册",description = "用户注册接口")
public BaseResponse<Boolean> register(@RequestBody UserRegisterDTO userRegisterDTO) {
userService.userRegister(userRegisterDTO);
return ResultUtils.success(true);
return ResultUtils.success(userService.userRegister(userRegisterDTO));
}
@PostMapping("/sendVerifyMail")
@@ -116,4 +123,26 @@ public class UserController {
public BaseResponse<Boolean> resetPassWord(@RequestBody ResetPassWordDTO resetPassWordDTO) {
return ResultUtils.success(userService.resetPassWord(resetPassWordDTO));
}
@PostMapping("/feedback")
@Operation(summary = "提交反馈", description = "用户提交反馈接口")
public BaseResponse<Boolean> submitFeedback(@RequestBody FeedbackSubmitReq req) {
KeyboardFeedback feedback = new KeyboardFeedback();
feedback.setContent(req.getContent());
feedback.setCreatedAt(new java.util.Date());
return ResultUtils.success(feedbackService.save(feedback));
}
@PostMapping("/bindInviteCode")
@Operation(summary = "绑定邀请码", description = "用户填写邀请码进行绑定")
public BaseResponse<Boolean> bindInviteCode(@RequestBody BindInviteCodeDTO 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));
}
}

View File

@@ -1,17 +1,19 @@
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.ResultUtils;
import com.yolo.keyborad.model.dto.PageDTO;
import com.yolo.keyborad.model.vo.wallet.KeyboardUserWalletRespVO;
import com.yolo.keyborad.model.vo.wallet.WalletTransactionRespVO;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import com.yolo.keyborad.service.KeyboardWalletTransactionService;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
/*
* @author: ziin
@@ -26,6 +28,9 @@ public class WalletController {
@Resource
private KeyboardUserWalletService walletService;
@Resource
private KeyboardWalletTransactionService transactionService;
@GetMapping("/balance")
@Operation(summary = "查询钱包余额", description = "查询当前登录用户的钱包余额")
public BaseResponse<KeyboardUserWalletRespVO> getBalance() {
@@ -33,4 +38,12 @@ public class WalletController {
KeyboardUserWalletRespVO balance = walletService.getWalletBalance(userId);
return ResultUtils.success(balance);
}
@PostMapping("/transactions")
@Operation(summary = "分页查询钱包交易记录", description = "分页查询当前用户的钱包交易记录")
public BaseResponse<IPage<WalletTransactionRespVO>> getTransactions(@RequestBody PageDTO pageDTO) {
Long userId = StpUtil.getLoginIdAsLong();
IPage<WalletTransactionRespVO> transactions = transactionService.getUserTransactions(userId, pageDTO.getPageNum(), pageDTO.getPageSize());
return ResultUtils.success(transactions);
}
}

View File

@@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
/**
* 全局异常处理器
@@ -25,6 +26,20 @@ public class GlobalExceptionHandler {
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)
public BaseResponse<?> businessExceptionHandler(BusinessException e, HttpServletRequest request) {
log.error("businessException: " + e.getMessage(), e);

View File

@@ -18,15 +18,27 @@ public class RequestBodyCacheFilter extends OncePerRequestFilter {
FilterChain filterChain)
throws ServletException, IOException {
// 只缓存一次
// 获取请求的内容类型
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);
// 使用缓存的请求对象继续执行过滤器链
filterChain.doFilter(cachedRequest, response);
return;
}
// 如果已经是缓存过的请求,则直接执行过滤器链
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,45 @@
package com.yolo.keyborad.listener;
import com.yolo.keyborad.model.entity.KeyboardCharacter;
import com.yolo.keyborad.service.KeyboardCharacterService;
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.List;
import java.util.concurrent.TimeUnit;
/**
* 人设缓存初始化器
* 在应用启动时将所有人设缓存到Redis
*/
@Component
@Slf4j
public class CharacterCacheInitializer implements ApplicationRunner {
private static final String CHARACTER_CACHE_KEY = "character:";
@Resource
private KeyboardCharacterService characterService;
@Resource(name = "objectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
@Override
public void run(ApplicationArguments args) {
try {
log.info("开始缓存人设列表到Redis...");
List<KeyboardCharacter> characters = characterService.list();
for (KeyboardCharacter character : characters) {
String key = CHARACTER_CACHE_KEY + character.getId();
redisTemplate.opsForValue().set(key, character, 7, TimeUnit.DAYS);
}
log.info("人设列表缓存完成,共缓存 {} 条记录", characters.size());
} catch (Exception e) {
log.error("缓存人设列表失败", e);
}
}
}

View File

@@ -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("已清除主题相关缓存");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardFeedback;
/*
* @author: ziin
* @date: 2025/12/17 17:06
*/
public interface KeyboardFeedbackMapper extends BaseMapper<KeyboardFeedback> {
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardProductItems;
/*
* @author: ziin
* @date: 2025/12/12 13:44
*/
public interface KeyboardProductItemsMapper extends BaseMapper<KeyboardProductItems> {
}

View File

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

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserInviteCodes;
/*
* @author: ziin
* @date: 2025/12/18 16:26
*/
public interface KeyboardUserInviteCodesMapper extends BaseMapper<KeyboardUserInviteCodes> {
}

View File

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

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
public interface KeyboardUserPurchaseRecordsMapper extends BaseMapper<KeyboardUserPurchaseRecords> {
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
/*
* @author: ziin
* @date: 2025/12/16 16:00
*/
public interface KeyboardUserQuotaTotalMapper extends BaseMapper<KeyboardUserQuotaTotal> {
}

View File

@@ -4,9 +4,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardWalletTransaction;
/*
* @author: ziin
* @date: 2025/12/10 18:54
*/
* @author: ziin
* @date: 2025/12/22 18:10
*/
public interface KeyboardWalletTransactionMapper extends BaseMapper<KeyboardWalletTransaction> {
}

View File

@@ -0,0 +1,41 @@
package com.yolo.keyborad.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Apple 服务器通知(精简字段)
*/
@Data
public class AppleServerNotification {
@JsonProperty("notification_type")
private String notificationType;
@JsonProperty("auto_renew_status")
private String autoRenewStatus;
@JsonProperty("app_account_token")
private String appAccountToken;
@JsonProperty("original_transaction_id")
private String originalTransactionId;
@JsonProperty("product_id")
private String productId;
@JsonProperty("purchase_date")
private String purchaseDate;
@JsonProperty("expires_date")
private String expiresDate;
@JsonProperty("environment")
private String environment;
@JsonProperty("transaction_id")
private String transactionId;
@JsonProperty("signed_transaction_info")
private String signedTransactionInfo;
}

View File

@@ -0,0 +1,18 @@
package com.yolo.keyborad.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/22 18:36
*/
@Data
public class PageDTO {
@Schema(description = "页码")
private Integer pageNum = 1;
@Schema(description = "每页数量")
private Integer pageSize = 10;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
package com.yolo.keyborad.model.dto.user;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 绑定邀请码请求
* @author: ziin
* @date: 2025/12/19
*/
@Data
@Schema(description = "绑定邀请码请求")
public class BindInviteCodeDTO {
/**
* 邀请码
*/
@Schema(description = "邀请码", required = true)
private String inviteCode;
}

View File

@@ -0,0 +1,18 @@
package com.yolo.keyborad.model.dto.user;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 用户反馈提交请求
*/
@Data
@Schema(description = "用户反馈提交请求")
public class FeedbackSubmitReq {
/**
* 反馈内容
*/
@Schema(description = "反馈内容", required = true)
private String content;
}

View File

@@ -24,4 +24,7 @@ public class UserRegisterDTO {
@Schema(description = "验证码")
private String verifyCode;
@Schema(description = "邀请码(可选)")
private String inviteCode;
}

View File

@@ -13,7 +13,7 @@ import lombok.Data;
@Schema(description="多语言消息表")
@Data
@TableName("i18n_message")
@TableName("keyboard_i18n_message")
public class I18nMessage {
@TableId("id")
@Schema(description="主键")

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/1/26 20:31
*/
/**
* 用户对AI陪伴角色的评论表支持多级评论结构一级评论与回复
*/
@Schema(description="用户对AI陪伴角色的评论表支持多级评论结构一级评论与回复")
@Data
@TableName(value = "keyboard_ai_companion_comment")
public class KeyboardAiCompanionComment {
/**
* 评论唯一ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="评论唯一ID")
private Long id;
/**
* 被评论的AI陪伴角色ID
*/
@TableField(value = "companion_id")
@Schema(description="被评论的AI陪伴角色ID")
private Long companionId;
/**
* 发表评论的用户ID
*/
@TableField(value = "user_id")
@Schema(description="发表评论的用户ID")
private Long userId;
/**
* 父评论IDNULL表示一级评论
*/
@TableField(value = "parent_id")
@Schema(description="父评论IDNULL表示一级评论")
private Long parentId;
/**
* 根评论ID用于标识同一评论线程
*/
@TableField(value = "root_id")
@Schema(description="根评论ID用于标识同一评论线程")
private Long rootId;
/**
* 评论内容
*/
@TableField(value = "content")
@Schema(description="评论内容")
private String content;
/**
* 点赞数量
*/
@TableField(value = "\"like\"")
@Schema(description="点赞数量")
private Long like;
/**
* 评论状态1=正常0=隐藏,-1=删除
*/
@TableField(value = "\"status\"")
@Schema(description="评论状态1=正常0=隐藏,-1=删除")
private Short status;
/**
* 评论点赞数
*/
@TableField(value = "like_count")
@Schema(description="评论点赞数")
private Integer likeCount;
/**
* 评论创建时间
*/
@TableField(value = "created_at")
@Schema(description="评论创建时间")
private Date createdAt;
/**
* 评论更新时间
*/
@TableField(value = "updated_at")
@Schema(description="评论更新时间")
private Date updatedAt;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
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: 2025/12/17 17:06
*/
/**
* 用户反馈表
*/
@Schema(description="用户反馈表")
@Data
@TableName(value = "keyboard_feedback")
public class KeyboardFeedback {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键ID")
private Long id;
/**
* 用户反馈内容
*/
@TableField(value = "content")
@Schema(description="用户反馈内容")
private String content;
/**
* 反馈创建时间
*/
@TableField(value = "created_at")
@Schema(description="反馈创建时间")
private Date createdAt;
}

View File

@@ -0,0 +1,111 @@
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.math.BigDecimal;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/12 13:44
*/
@Schema
@Data
@TableName(value = "keyboard_product_items")
public class KeyboardProductItems {
/**
* 主键,自增,唯一标识每个产品项
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键,自增,唯一标识每个产品项")
private Long id;
/**
* 产品标识符,唯一标识每个产品(如 com.loveKey.nyx.2month
*/
@TableField(value = "product_id")
@Schema(description="产品标识符,唯一标识每个产品(如 com.loveKey.nyx.2month")
private String productId;
/**
* 产品类型区分订阅subscription和内购in-app-purchase
*/
@TableField(value = "\"type\"")
@Schema(description="产品类型区分订阅subscription和内购in-app-purchase")
private String type;
/**
* 产品名称(如 100, 2
*/
@TableField(value = "\"name\"")
@Schema(description="产品名称(如 100, 2")
private String name;
/**
* 产品单位(如 金币,个月)
*/
@TableField(value = "unit")
@Schema(description="产品单位(如 金币,个月)")
private String unit;
/**
* 订阅时长的数值部分(如 2
*/
@TableField(value = "duration_value")
@Schema(description="订阅时长的数值部分(如 2")
private Integer durationValue;
/**
* 订阅时长的单位部分(如 月,天)
*/
@TableField(value = "duration_unit")
@Schema(description="订阅时长的单位部分(如 月,天)")
private String durationUnit;
/**
* 产品价格
*/
@TableField(value = "price")
@Schema(description="产品价格")
private BigDecimal price;
/**
* 产品的货币单位,如美元($
*/
@TableField(value = "currency")
@Schema(description="产品的货币单位,如美元($")
private String currency;
/**
* 产品的描述,提供更多细节信息
*/
@TableField(value = "description")
@Schema(description="产品的描述,提供更多细节信息")
private String description;
/**
* 产品项的创建时间,默认当前时间
*/
@TableField(value = "created_at")
@Schema(description="产品项的创建时间,默认当前时间")
private Date createdAt;
/**
* 产品项的最后更新时间,更新时自动设置为当前时间
*/
@TableField(value = "updated_at")
@Schema(description="产品项的最后更新时间,更新时自动设置为当前时间")
private Date updatedAt;
/**
* 订阅时长的具体天数
*/
@TableField(value = "duration_days")
@Schema(description="订阅时长的具体天数")
private Integer durationDays;
}

View File

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

View File

@@ -0,0 +1,109 @@
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.math.BigDecimal;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/17 13:29
*/
/**
* 用户每次调用日志用于记录token、模型、耗时、成功率等
*/
@Schema(description="用户每次调用日志用于记录token、模型、耗时、成功率等")
@Data
@TableName(value = "keyboard_user_call_log")
public class KeyboardUserCallLog {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="")
private Long id;
/**
* 用户ID
*/
@TableField(value = "user_id")
@Schema(description="用户ID")
private Long userId;
/**
* 幂等请求ID避免重试导致重复记录
*/
@TableField(value = "request_id")
@Schema(description="幂等请求ID避免重试导致重复记录")
private String requestId;
/**
* 调用功能来源
*/
@TableField(value = "feature")
@Schema(description="调用功能来源")
private String feature;
/**
* 调用的模型名称
*/
@TableField(value = "model")
@Schema(description="调用的模型名称")
private String model;
/**
* 输入token数
*/
@TableField(value = "input_tokens")
@Schema(description="输入token数")
private Integer inputTokens;
/**
* 输出token数
*/
@TableField(value = "output_tokens")
@Schema(description="输出token数")
private Integer outputTokens;
/**
* 总token数input+output
*/
@TableField(value = "total_tokens")
@Schema(description="总token数input+output")
private Integer totalTokens;
/**
* 调用是否成功
*/
@TableField(value = "success")
@Schema(description="调用是否成功")
private Boolean success;
/**
* 调用耗时(毫秒)
*/
@TableField(value = "latency_ms")
@Schema(description="调用耗时(毫秒)")
private Integer latencyMs;
/**
* 失败错误码(可空)
*/
@TableField(value = "error_code")
@Schema(description="失败错误码(可空)")
private String errorCode;
/**
* 调用记录创建时间
*/
@TableField(value = "created_at")
@Schema(description="调用记录创建时间")
private Date createdAt;
@TableField(value = "gen_id")
@Schema(description="生成 Id")
private String genId;
}

View File

@@ -0,0 +1,97 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/18 16:26
*/
/**
* 用户生成的邀请码表,用于邀请新用户注册/安装并建立邀请关系
*/
@Schema(description="用户生成的邀请码表,用于邀请新用户注册/安装并建立邀请关系")
@Data
@KeySequence("invite_codes_id_seq")
@TableName(value = "keyboard_user_invite_codes")
public class KeyboardUserInviteCodes {
/**
* 邀请码主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="邀请码主键ID")
private Long id;
/**
* 邀请码字符串,对外展示,唯一
*/
@TableField(value = "code")
@Schema(description="邀请码字符串,对外展示,唯一")
private String code;
/**
* 邀请码所属用户ID邀请人
*/
@TableField(value = "owner_user_id")
@Schema(description="邀请码所属用户ID邀请人")
private Long ownerUserId;
/**
* 邀请码状态1=启用0=停用
*/
@TableField(value = "\"status\"")
@Schema(description="邀请码状态1=启用0=停用")
private Short status;
/**
* 邀请码创建时间
*/
@TableField(value = "created_at")
@Schema(description="邀请码创建时间")
private Date createdAt;
/**
* 邀请码过期时间NULL表示永久有效
*/
@TableField(value = "expires_at")
@Schema(description="邀请码过期时间NULL表示永久有效")
private Date expiresAt;
/**
* 邀请码最大可使用次数NULL表示不限次数
*/
@TableField(value = "max_uses")
@Schema(description="邀请码最大可使用次数NULL表示不限次数")
private Integer maxUses;
/**
* 邀请码已使用次数
*/
@TableField(value = "used_count")
@Schema(description="邀请码已使用次数")
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;
}

View File

@@ -0,0 +1,120 @@
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: 2025/12/29 13:58
*/
/**
* 用户邀请关系绑定台账表,记录新用户最终归属的邀请人
*/
@Schema(description = "用户邀请关系绑定台账表,记录新用户最终归属的邀请人")
@Data
@TableName(value = "keyboard_user_invites")
public class KeyboardUserInvites {
/**
* 邀请绑定记录主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "邀请绑定记录主键ID")
private Long id;
/**
* 邀请人用户ID
*/
@TableField(value = "inviter_user_id")
@Schema(description = "邀请人用户ID")
private Long inviterUserId;
/**
* 被邀请人用户ID新注册用户
*/
@TableField(value = "invitee_user_id")
@Schema(description = "被邀请人用户ID新注册用户")
private Long inviteeUserId;
/**
* 使用的邀请码ID
*/
@TableField(value = "invite_code_id")
@Schema(description = "使用的邀请码ID")
private Long inviteCodeId;
/**
* 绑定时关联的点击Token通过邀请链接自动绑定时使用
*/
@TableField(value = "click_token")
@Schema(description = "绑定时关联的点击Token通过邀请链接自动绑定时使用")
private String clickToken;
/**
* 绑定方式1=手动填写邀请码2=邀请链接自动绑定3=其他方式
*/
@TableField(value = "bind_type")
@Schema(description = "绑定方式1=手动填写邀请码2=邀请链接自动绑定3=其他方式")
private Short bindType;
/**
* 邀请关系绑定完成时间
*/
@TableField(value = "bound_at")
@Schema(description = "邀请关系绑定完成时间")
private Date boundAt;
/**
* 绑定 iP
*/
@TableField(value = "bind_ip")
@Schema(description = "绑定 iP")
private String bindIp;
/**
* userAgent
*/
@TableField(value = "bind_user_agent")
@Schema(description = "userAgent")
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;
}

View File

@@ -0,0 +1,134 @@
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 com.yolo.keyborad.typehandler.StringArrayTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
import org.apache.ibatis.type.JdbcType;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
@Schema
@Data
@TableName(value = "keyboard_user_purchase_records")
public class KeyboardUserPurchaseRecords {
/**
* 主键,自增,唯一标识每条购买记录
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键,自增,唯一标识每条购买记录")
private Integer id;
/**
* 用户 ID关联到用户表表示是哪位用户购买了产品
*/
@TableField(value = "user_id")
@Schema(description="用户 ID关联到用户表表示是哪位用户购买了产品")
private Integer userId;
/**
* 购买的产品 ID关联到产品表
*/
@TableField(value = "product_id")
@Schema(description="购买的产品 ID关联到产品表")
private String productId;
/**
* 购买数量(如内购的金币数量,订阅的时长)
*/
@TableField(value = "purchase_quantity")
@Schema(description="购买数量(如内购的金币数量,订阅的时长)")
private Integer purchaseQuantity;
/**
* 实际支付价格
*/
@TableField(value = "price")
@Schema(description="实际支付价格")
private BigDecimal price;
/**
* 货币类型(如美元 $
*/
@TableField(value = "currency")
@Schema(description="货币类型(如美元 $")
private String currency;
/**
* 购买时间
*/
@TableField(value = "purchase_time")
@Schema(description="购买时间")
private Date purchaseTime;
/**
* 购买类型(如内购,订阅)
*/
@TableField(value = "purchase_type")
@Schema(description="购买类型(如内购,订阅)")
private String purchaseType;
/**
* 购买状态(如已支付,待支付,退款)
*/
@TableField(value = "\"status\"")
@Schema(description="购买状态(如已支付,待支付,退款)")
private String status;
/**
* 支付方式(如信用卡,支付宝等)
*/
@TableField(value = "payment_method")
@Schema(description="支付方式(如信用卡,支付宝等)")
private String paymentMethod;
/**
* 唯一的交易 ID用于标识该购买操作
*/
@TableField(value = "transaction_id")
@Schema(description="唯一的交易 ID用于标识该购买操作")
private String transactionId;
/**
* 苹果的原始交易 ID
*/
@TableField(value = "original_transaction_id")
@Schema(description="苹果的原始交易 ID")
private String originalTransactionId;
/**
* 购买的产品 ID 列表JSON 格式或数组)
*/
@TableField(value = "product_ids", typeHandler = StringArrayTypeHandler.class, jdbcType = JdbcType.ARRAY)
@Schema(description="购买的产品 ID 列表JSON 格式或数组)")
private String[] productIds;
/**
* 苹果返回的购买时间
*/
@TableField(value = "purchase_date")
@Schema(description="苹果返回的购买时间")
private Date purchaseDate;
/**
* 苹果返回的过期时间(如果有)
*/
@TableField(value = "expires_date")
@Schema(description="苹果返回的过期时间(如果有)")
private Date expiresDate;
/**
* 苹果的环境(如 Sandbox 或 Production
*/
@TableField(value = "environment")
@Schema(description="苹果的环境(如 Sandbox 或 Production")
private String environment;
}

View File

@@ -0,0 +1,64 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/16 16:00
*/
/**
* 用户免费功能永久总次数额度表(所有功能共用)
*/
@Schema(description="用户免费功能永久总次数额度表(所有功能共用)")
@Data
@TableName(value = "keyboard_user_quota_total")
public class KeyboardUserQuotaTotal {
/**
* 用户唯一ID对应系统用户
*/
@TableId(value = "user_id", type = IdType.AUTO)
@Schema(description="用户唯一ID对应系统用户")
private Long userId;
/**
* 免费体验的永久总次数上限(可通过运营活动增加)
*/
@TableField(value = "total_quota")
@Schema(description="免费体验的永久总次数上限(可通过运营活动增加)")
private Integer totalQuota;
/**
* 已消耗的免费次数
*/
@TableField(value = "used_quota")
@Schema(description="已消耗的免费次数")
private Integer usedQuota;
/**
* 乐观锁版本号(并发控制预留字段)
*/
@TableField(value = "version")
@Schema(description="乐观锁版本号(并发控制预留字段)")
private Integer version;
/**
* 首次创建额度记录的时间(通常为注册时间)
*/
@TableField(value = "created_at")
@Schema(description="首次创建额度记录的时间(通常为注册时间)")
private Date createdAt;
/**
* 最近一次额度发生变化的时间(消耗或赠送)
*/
@TableField(value = "updated_at")
@Schema(description="最近一次额度发生变化的时间(消耗或赠送)")
private Date updatedAt;
}

View File

@@ -11,9 +11,9 @@ import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/10 18:54
* @date: 2025/12/22 18:10
*/
@Schema
@Data
@TableName(value = "keyboard_wallet_transaction")
@@ -22,62 +22,62 @@ public class KeyboardWalletTransaction {
* 主键 Id
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键 Id")
@Schema(description = "主键 Id")
private Long id;
/**
* 用户 Id
*/
@TableField(value = "user_id")
@Schema(description="用户 Id")
@Schema(description = "用户 Id")
private Long userId;
/**
* 订单 Id
*/
@TableField(value = "order_id")
@Schema(description="订单 Id")
@Schema(description = "订单 Id")
private Long orderId;
/**
* 金额
*/
@TableField(value = "amount")
@Schema(description="金额")
@Schema(description = "金额")
private BigDecimal amount;
/**
* 变动类型
*/
@TableField(value = "\"type\"")
@Schema(description="变动类型")
@Schema(description = "变动类型")
private Short type;
/**
* 变动前余额
*/
@TableField(value = "before_balance")
@Schema(description="变动前余额")
@Schema(description = "变动前余额")
private BigDecimal beforeBalance;
/**
* 变动后余额
*/
@TableField(value = "after_balance")
@Schema(description="变动后余额")
@Schema(description = "变动后余额")
private BigDecimal afterBalance;
/**
* 描述
*/
@TableField(value = "description")
@Schema(description="描述")
@Schema(description = "描述")
private String description;
/**
* 创建时间
*/
@TableField(value = "created_at")
@Schema(description="创建时间")
@Schema(description = "创建时间")
private Date createdAt;
}

View File

@@ -1,43 +0,0 @@
package com.yolo.keyborad.model.enums;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 帖子审核状态枚举
*
* @author yupi
*/
public enum PostReviewStatusEnum {
REVIEWING("待审核", 0),
PASS("通过", 1),
REJECT("拒绝", 2);
private final String text;
private final int value;
PostReviewStatusEnum(String text, int value) {
this.text = text;
this.value = value;
}
/**
* 获取值列表
*
* @return
*/
public static List<Integer> getValues() {
return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList());
}
public int getValue() {
return value;
}
public String getText() {
return text;
}
}

View 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;
}

View File

@@ -0,0 +1,37 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 音频任务状态
*
* @author ziin
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "音频任务状态")
public class AudioTaskVO {
@Schema(description = "音频任务 ID")
private String audioId;
@Schema(description = "任务状态: pending/processing/completed/failed")
private String status;
@Schema(description = "音频 URL (completed 时返回)")
private String audioUrl;
@Schema(description = "错误信息 (failed 时返回)")
private String errorMessage;
public static final String STATUS_PENDING = "pending";
public static final String STATUS_PROCESSING = "processing";
public static final String STATUS_COMPLETED = "completed";
public static final String STATUS_FAILED = "failed";
}

View File

@@ -0,0 +1,27 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
/*
* @author: ziin
* @date: 2026/1/26
*/
@Data
@Schema(description = "聊天消息VO")
public class ChatMessageHistoryVO {
@Schema(description = "消息ID")
private Long id;
@Schema(description = "消息发送方1=用户2=AI")
private Short sender;
@Schema(description = "聊天消息内容")
private String content;
@Schema(description = "消息创建时间")
private Date createdAt;
}

View File

@@ -0,0 +1,29 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 消息响应(含异步音频)
*
* @author ziin
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "消息响应")
public class ChatMessageVO {
@Schema(description = "AI 响应文本")
private String aiResponse;
@Schema(description = "音频任务 ID用于查询音频状态")
private String audioId;
@Schema(description = "LLM 耗时(毫秒)")
private Long llmDuration;
}

View File

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

View File

@@ -0,0 +1,32 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 语音对话响应
*
* @author ziin
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "语音对话响应")
public class ChatVoiceVO {
@Schema(description = "用户输入内容")
private String content;
@Schema(description = "AI 响应文本")
private String aiResponse;
@Schema(description = "AI 语音音频 URL (R2)")
private String audioUrl;
@Schema(description = "处理耗时(毫秒)")
private Long duration;
}

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
package com.yolo.keyborad.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* TTS 语音合成结果
*
* @author ziin
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "TTS 语音合成结果")
public class TextToSpeechVO {
@Schema(description = "音频 Base64")
private String audioBase64;
@Schema(description = "音频 URL (R2)")
private String audioUrl;
}

View File

@@ -0,0 +1,48 @@
package com.yolo.keyborad.model.vo.products;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import lombok.Data;
/**
* 商品明细返回 VO
*/
@Data
@Schema(description = "商品明细返回对象")
public class KeyboardProductItemRespVO {
@Schema(description = "主键ID")
private Long id;
@Schema(description = "产品标识符,如 com.loveKey.nyx.2month")
private String productId;
@Schema(description = "产品类型subscription / in-app-purchase")
private String type;
@Schema(description = "产品名称")
private String name;
@Schema(description = "产品单位")
private String unit;
@Schema(description = "订阅时长数值")
private Integer durationValue;
@Schema(description = "订阅时长单位")
private String durationUnit;
@Schema(description = "订阅时长天数")
private Integer durationDays;
@Schema(description = "价格")
private BigDecimal price;
@Schema(description = "货币单位")
private String currency;
@Schema(description = "描述")
private String description;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
package com.yolo.keyborad.model.vo.wallet;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.math.BigDecimal;
import java.util.Date;
@Data
@Schema(description = "钱包交易记录响应")
public class WalletTransactionRespVO {
@Schema(description = "交易ID")
private Long id;
@Schema(description = "金额")
private BigDecimal amount;
@Schema(description = "变动类型")
private Short type;
@Schema(description = "变动前余额")
private BigDecimal beforeBalance;
@Schema(description = "变动后余额")
private BigDecimal afterBalance;
@Schema(description = "描述")
private String description;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createdAt;
}

View File

@@ -0,0 +1,46 @@
package com.yolo.keyborad.service;
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
/**
* 处理苹果购买后的业务逻辑
*/
public interface ApplePurchaseService {
/**
* 基于验签结果处理购买逻辑(订阅 / 内购)
*
* @param userId 用户ID
* @param validationResult 苹果验签结果
*/
void processPurchase(Long userId, AppleReceiptValidationResult validationResult);
/**
* 处理订阅相关通知(新订阅、续订、续订失败、过期等)
*
* @param notification 解码后的通知载荷
*/
void handleSubscriptionNotification(ResponseBodyV2DecodedPayload notification);
/**
* 处理退款相关通知(退款、退款拒绝、退款撤销)
*
* @param notification 解码后的通知载荷
*/
void handleRefundNotification(ResponseBodyV2DecodedPayload notification);
/**
* 处理续订偏好变更通知
*
* @param notification 解码后的通知载荷
*/
void handleRenewalPreferenceChange(ResponseBodyV2DecodedPayload notification);
/**
* 处理消费请求通知
*
* @param notification 解码后的通知载荷
*/
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
}

View File

@@ -5,9 +5,17 @@ import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
public interface AppleReceiptService {
/**
* 验证 base64 app receipt 是否有效,并返回解析结果。
* 验证 JWS 交易数据是否有效,并返回解析结果。
*
* @param appReceipt Base64 的 app receipt以 MI... 开头那串)
* @param signedTransaction JWS 格式的签名交易数据
*/
AppleReceiptValidationResult validateReceipt(String appReceipt);
AppleReceiptValidationResult validateReceipt(String signedTransaction);
/**
* 处理 Apple 服务器通知
* 验证通知签名并根据通知类型分发到相应的处理逻辑
*
* @param signedPayload Apple 服务器发送的签名载荷JWT 格式)
*/
void processNotification(String signedPayload);
}

Some files were not shown because too many files have changed in this diff Show More