From c652d0ddf38b9b710823f2c1dba75c0cb32a9e5d Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Thu, 13 Nov 2025 01:06:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/backend.mdc | 373 +++++ {.cursorules => .cursor/rules}/vue.md | 0 .cursorules/design.md | 63 - frontend/api/README.md | 129 ++ frontend/api/axios/client.js | 152 ++ frontend/api/services.js | 113 -- frontend/api/services/index.js | 11 + frontend/api/services/tikhub.js | 43 + frontend/app/web-gold/src/api/README.md | 118 -- frontend/app/web-gold/src/api/chat.js | 3 +- frontend/app/web-gold/src/api/common.js | 58 +- frontend/app/web-gold/src/api/config.js | 14 - frontend/app/web-gold/src/api/example.js | 72 - frontend/app/web-gold/src/api/http.js | 260 +-- frontend/app/web-gold/src/api/index.js | 23 +- frontend/app/web-gold/src/api/tikhub.js | 5 - .../app/web-gold/src/api/tikhub/README.md | 180 --- frontend/app/web-gold/src/api/tikhub/index.js | 3 +- frontend/app/web-gold/src/api/userPrompt.js | 68 + .../src/components/ChatMessageRenderer.vue | 280 ++-- .../web-gold/src/components/SidebarNav.vue | 6 + frontend/app/web-gold/src/router/index.js | 8 + frontend/app/web-gold/src/utils/streamChat.js | 13 +- .../src/views/content-style/Benchmark.vue | 1430 ++--------------- .../src/views/content-style/Copywriting.vue | 412 ++++- .../components/BatchAnalyzeModal.vue | 242 +++ .../components/BenchmarkForm.vue | 126 ++ .../components/BenchmarkTable.vue | 232 +++ .../components/ExpandedRowContent.vue | 195 +++ .../components/SavePromptModal.vue | 138 ++ .../composables/useBenchmarkAnalysis.js | 157 ++ .../composables/useBenchmarkData.js | 80 + .../content-style/utils/benchmarkUtils.js | 60 + .../src/views/system/StyleSettings.vue | 489 ++++++ frontend/config/api.js | 2 + frontend/hooks/web/README.md | 31 +- frontend/hooks/web/useUserInfo.js | 4 - frontend/hooks/web/useVoiceText.ts | 48 +- frontend/utils/TOKEN_STORAGE.md | 160 -- .../app/AppAiChatConversationController.java | 89 + .../app/AppAiChatMessageController.java | 194 +++ .../AppAiChatConversationCreateMyReqVO.java | 17 + .../vo/app/AppAiChatConversationRespVO.java | 67 + .../AppAiChatConversationUpdateMyReqVO.java | 40 + .../chat/vo/app/AppAiChatMessageRespVO.java | 81 + .../vo/app/AppAiChatMessageSendReqVO.java | 32 + .../vo/app/AppAiChatMessageSendRespVO.java | 69 + .../AppUserPromptController.java} | 95 +- .../service/UserPromptServiceImpl.java | 39 +- 49 files changed, 4072 insertions(+), 2452 deletions(-) create mode 100644 .cursor/rules/backend.mdc rename {.cursorules => .cursor/rules}/vue.md (100%) delete mode 100644 .cursorules/design.md create mode 100644 frontend/api/README.md create mode 100644 frontend/api/axios/client.js delete mode 100644 frontend/api/services.js create mode 100644 frontend/api/services/index.js create mode 100644 frontend/api/services/tikhub.js delete mode 100644 frontend/app/web-gold/src/api/README.md delete mode 100644 frontend/app/web-gold/src/api/config.js delete mode 100644 frontend/app/web-gold/src/api/example.js delete mode 100644 frontend/app/web-gold/src/api/tikhub.js delete mode 100644 frontend/app/web-gold/src/api/tikhub/README.md create mode 100644 frontend/app/web-gold/src/api/userPrompt.js create mode 100644 frontend/app/web-gold/src/views/content-style/components/BatchAnalyzeModal.vue create mode 100644 frontend/app/web-gold/src/views/content-style/components/BenchmarkForm.vue create mode 100644 frontend/app/web-gold/src/views/content-style/components/BenchmarkTable.vue create mode 100644 frontend/app/web-gold/src/views/content-style/components/ExpandedRowContent.vue create mode 100644 frontend/app/web-gold/src/views/content-style/components/SavePromptModal.vue create mode 100644 frontend/app/web-gold/src/views/content-style/composables/useBenchmarkAnalysis.js create mode 100644 frontend/app/web-gold/src/views/content-style/composables/useBenchmarkData.js create mode 100644 frontend/app/web-gold/src/views/content-style/utils/benchmarkUtils.js create mode 100644 frontend/app/web-gold/src/views/system/StyleSettings.vue delete mode 100644 frontend/utils/TOKEN_STORAGE.md create mode 100644 yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/controller/app/AppAiChatConversationController.java create mode 100644 yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/controller/app/AppAiChatMessageController.java create mode 100644 yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationCreateMyReqVO.java create mode 100644 yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationRespVO.java create mode 100644 yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationUpdateMyReqVO.java create mode 100644 yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageRespVO.java create mode 100644 yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageSendReqVO.java create mode 100644 yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageSendRespVO.java rename yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/{UserPromptController.java => app/AppUserPromptController.java} (51%) diff --git a/.cursor/rules/backend.mdc b/.cursor/rules/backend.mdc new file mode 100644 index 0000000000..49cdf5cdab --- /dev/null +++ b/.cursor/rules/backend.mdc @@ -0,0 +1,373 @@ +--- +description: RuoYi Spring Boot 后端开发最佳实践与规范 +globs: **/*.java, **/*.xml, **/*.yaml, **/*.yml +--- + +# RuoYi Spring Boot 后端开发规范 + +## 项目架构 + +### 模块结构 +- `yudao-dependencies`: Maven 依赖版本统一管理 +- `yudao-framework`: 框架拓展组件(技术组件) +- `yudao-server`: 服务启动模块 +- `yudao-module-*`: 业务模块(如 system、member、ai 等) + +### 分层架构 +- **Controller 层**: 接收请求,参数校验,调用 Service +- **Service 层**: 业务逻辑处理,事务管理 +- **Mapper 层**: 数据访问,使用 MyBatis Plus +- **VO 层**: 视图对象,用于前后端交互 +- **DO 层**: 数据对象,对应数据库表 + +## Controller 层规范 + +### 包结构 +- `controller.admin.*`: 管理后台接口 +- `controller.app.*`: 用户端接口(C 端) +- App Controller 和 VO 必须添加 `App` 前缀 + +### 注解使用 +- 使用 `@RestController` 而非 `@Controller` +- 使用 `@RequestMapping` 定义基础路径 +- 使用 `@Tag` 定义 Swagger 文档标签 +- 使用 `@Operation` 定义接口说明 +- 使用 `@Parameter` 定义参数说明 +- 使用 `@Valid` 或 `@Validated` 进行参数校验 + +### 权限控制 +- 管理后台接口使用 `@PreAuthorize("@ss.hasPermission('module:resource:action')")` +- 用户端接口通过 `getLoginUserId()` 获取当前用户,确保数据隔离 +- 使用 `@PermitAll` 标记允许匿名访问的接口 + +### 返回值规范 +- 统一使用 `CommonResult` 包装返回值 +- 使用 `success()` 静态方法返回成功结果 +- 异常由全局异常处理器统一处理 + +### 代码示例 +```java +@Tag(name = "管理后台 - 用户提示词") +@RestController +@RequestMapping("/ai/user-prompt") +@Validated +public class UserPromptController { + + @Resource + private UserPromptService userPromptService; + + @PostMapping("/create") + @Operation(summary = "创建用户提示词") + @PreAuthorize("@ss.hasPermission('ai:user-prompt:create')") + public CommonResult createUserPrompt(@Valid @RequestBody UserPromptSaveReqVO createReqVO) { + return success(userPromptService.createUserPrompt(createReqVO)); + } +} +``` + +## Service 层规范 + +### 接口与实现 +- Service 接口定义在 `service` 包下 +- Service 实现类使用 `ServiceImpl` 后缀,实现对应接口 +- 使用 `@Service` 注解标记 +- 使用 `@Validated` 启用参数校验 + +### 事务管理 +- 涉及数据库写操作的方法使用 `@Transactional(rollbackFor = Exception.class)` +- 查询方法不需要事务注解 +- 避免在 Service 方法中捕获异常后不抛出,导致事务无法回滚 + +### 业务逻辑 +- Service 层处理核心业务逻辑 +- 使用 `BeanUtils.toBean()` 进行对象转换 +- 使用 `validateXxxExists()` 方法校验数据存在性 +- 使用 `ServiceExceptionUtil.exception()` 抛出业务异常 + +### 代码示例 +```java +@Service +@Validated +public class UserPromptServiceImpl implements UserPromptService { + + @Resource + private UserPromptMapper userPromptMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createUserPrompt(UserPromptSaveReqVO createReqVO) { + // 1. 校验 + // 2. 转换 + UserPromptDO userPrompt = BeanUtils.toBean(createReqVO, UserPromptDO.class); + // 3. 插入 + userPromptMapper.insert(userPrompt); + // 4. 返回 + return userPrompt.getId(); + } +} +``` + +## Mapper 层规范 + +### 继承规范 +- Mapper 接口继承 `BaseMapperX`,而非 `BaseMapper` +- `BaseMapperX` 提供了更强大的查询能力 + +### 方法命名 +- 查询方法使用 `select` 前缀 +- 插入方法使用 `insert` 前缀 +- 更新方法使用 `update` 前缀 +- 删除方法使用 `delete` 前缀 + +### 分页查询 +- 使用 `selectPage(PageReqVO pageReqVO)` 进行分页查询 +- 使用 `LambdaQueryWrapperX` 构建查询条件 + +### 代码示例 +```java +@Mapper +public interface UserPromptMapper extends BaseMapperX { + + default PageResult selectPage(UserPromptPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(UserPromptDO::getName, reqVO.getName()) + .eqIfPresent(UserPromptDO::getCategory, reqVO.getCategory()) + .betweenIfPresent(UserPromptDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(UserPromptDO::getId)); + } +} +``` + +## VO 对象规范 + +### 命名规范 +- Request VO: `XxxSaveReqVO`、`XxxPageReqVO`、`XxxUpdateReqVO` +- Response VO: `XxxRespVO` +- App VO: `AppXxxReqVO`、`AppXxxRespVO` + +### 字段注解 +- 使用 `@Schema` 定义字段说明和示例 +- 使用 `@NotNull`、`@NotEmpty`、`@NotBlank` 等校验注解 +- 使用 `requiredMode = Schema.RequiredMode.REQUIRED` 标记必填字段 + +### 对象转换 +- Controller 层使用 `BeanUtils.toBean()` 进行 DO 到 VO 的转换 +- Service 层使用 `BeanUtils.toBean()` 进行 VO 到 DO 的转换 +- 复杂转换使用 MapStruct 或手动转换 + +### 代码示例 +```java +@Schema(description = "管理后台 - 用户提示词创建 Request VO") +@Data +public class UserPromptSaveReqVO { + + @Schema(description = "提示词名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 开发助手") + @NotBlank(message = "提示词名称不能为空") + private String name; + + @Schema(description = "提示词内容", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "提示词内容不能为空") + private String content; +} +``` + +## DO 对象规范 + +### 继承规范 +- 普通 DO 继承 `BaseDO`,包含 `id`、`createTime`、`updateTime`、`creator`、`updater`、`deleted` +- 需要多租户的 DO 继承 `TenantBaseDO`,额外包含 `tenantId` +- 使用 `@TableName` 指定表名 + +### 字段规范 +- 使用 `@TableId(type = IdType.AUTO)` 指定主键策略 +- 使用 `@TableLogic` 标记逻辑删除字段 +- 字段名使用驼峰命名,对应数据库下划线命名 + +### 代码示例 +```java +@TableName("ai_user_prompt") +@Data +@EqualsAndHashCode(callSuper = true) +public class UserPromptDO extends TenantBaseDO { + + @TableId(type = IdType.AUTO) + private Long id; + + private String name; + private String content; + private String category; +} +``` + +## 异常处理规范 + +### 异常定义 +- 业务异常使用 `ServiceException`,通过 `ServiceExceptionUtil.exception()` 创建 +- 异常码定义在 `ErrorCodeConstants` 中 +- 使用全局异常处理器统一处理 + +### 异常码规范 +- 格式:`MODULE_RESOURCE_ACTION_ERROR` +- 例如:`USER_PROMPT_NOT_EXISTS`、`USER_PROMPT_NAME_DUPLICATE` + +### 代码示例 +```java +// 定义异常码 +public interface ErrorCodeConstants { + ErrorCode USER_PROMPT_NOT_EXISTS = new ErrorCode(1_010_000_001, "用户提示词不存在"); +} + +// 使用异常 +if (userPrompt == null) { + throw exception(USER_PROMPT_NOT_EXISTS); +} +``` + +## 多租户规范 + +### DO 继承 +- 需要多租户的数据表,DO 继承 `TenantBaseDO` +- 框架自动注入 `tenantId`,无需手动设置 + +### 数据隔离 +- Mapper 查询时,框架自动添加租户条件 +- 跨租户操作需要特殊处理 + +### 代码示例 +```java +// DO 继承 TenantBaseDO +public class UserPromptDO extends TenantBaseDO { + // tenantId 自动注入,无需手动定义 +} + +// Service 中无需关心租户,框架自动处理 +public UserPromptDO getUserPrompt(Long id) { + return userPromptMapper.selectById(id); // 自动添加租户条件 +} +``` + +## 权限控制规范 + +### 权限标识 +- 格式:`模块:资源:操作` +- 例如:`ai:user-prompt:create`、`ai:user-prompt:query`、`ai:user-prompt:update`、`ai:user-prompt:delete` + +### 权限注解 +- 使用 `@PreAuthorize("@ss.hasPermission('module:resource:action')")` +- 查询操作使用 `query`,创建使用 `create`,更新使用 `update`,删除使用 `delete` + +## API 路径规范 + +### 路径前缀 +- 管理后台:`/admin-api` +- 用户端:`/app-api` +- Controller 路径:`/模块/资源`,如 `/ai/user-prompt` + +### HTTP 方法 +- GET: 查询操作 +- POST: 创建操作 +- PUT: 更新操作 +- DELETE: 删除操作 + +### 接口路径 +- 创建:`POST /模块/资源/create` +- 更新:`PUT /模块/资源/update` +- 删除:`DELETE /模块/资源/delete` +- 查询单个:`GET /模块/资源/get?id=xxx` +- 分页查询:`GET /模块/资源/page` +- 导出:`GET /模块/资源/export-excel` + +## 代码质量规范 + +### 命名规范 +- 类名使用大驼峰(PascalCase) +- 方法名和变量名使用小驼峰(camelCase) +- 常量使用大写下划线(UPPER_SNAKE_CASE) +- 包名全小写,使用点分隔 + +### 注释规范 +- 类和方法必须有 JavaDoc 注释 +- 复杂业务逻辑添加行内注释 +- 使用 `@author` 标记作者 + +### 代码格式 +- 使用 4 个空格缩进 +- 每行代码不超过 120 个字符 +- 方法参数过多时换行对齐 +- 使用 IDE 格式化快捷键统一格式 + +### 导入规范 +- 使用静态导入简化代码:`import static ...` +- 避免使用 `.*` 通配符导入 +- 导入顺序:Java 标准库 → 第三方库 → 项目内部 + +## 性能优化规范 + +### 数据库查询 +- 避免 N+1 查询问题,使用批量查询 +- 合理使用索引,避免全表扫描 +- 分页查询必须限制每页数量 + +### 缓存使用 +- 热点数据使用 Redis 缓存 +- 缓存 key 使用统一前缀:`模块:资源:id` +- 注意缓存更新和失效策略 + +### 事务优化 +- 查询方法不使用事务 +- 事务范围尽可能小 +- 避免在事务中进行远程调用 + +## 安全规范 + +### 参数校验 +- 所有用户输入必须校验 +- 使用 `@Valid` 和 JSR-303 注解 +- 敏感操作进行二次校验 + +### SQL 注入防护 +- 使用 MyBatis Plus 的参数化查询 +- 禁止拼接 SQL 语句 +- 使用 `LambdaQueryWrapperX` 构建查询条件 + +### 权限校验 +- 所有接口必须进行权限校验 +- 数据操作前校验数据归属 +- 敏感操作记录操作日志 + +## 测试规范 + +### 单元测试 +- Service 层方法编写单元测试 +- 使用 Mockito 模拟依赖 +- 测试覆盖率不低于 70% + +### 集成测试 +- 关键业务流程编写集成测试 +- 使用 `@SpringBootTest` 进行集成测试 +- 测试数据使用独立的测试数据库 + +## 日志规范 + +### 日志级别 +- ERROR: 系统错误,需要立即处理 +- WARN: 警告信息,需要关注 +- INFO: 关键业务流程日志 +- DEBUG: 调试信息,生产环境关闭 + +### 日志格式 +- 使用 SLF4J + Logback +- 日志包含:时间、级别、线程、类名、消息 +- 关键操作记录操作日志(使用 `@ApiAccessLog`) + +## 配置管理 + +### 配置文件 +- 使用 `application.yaml` 作为主配置 +- 使用 `application-{profile}.yaml` 作为环境配置 +- 敏感信息使用环境变量或配置中心 + +### 配置类 +- 使用 `@ConfigurationProperties` 绑定配置 +- 配置类使用 `@Validated` 进行校验 +- 提供默认值和说明文档 diff --git a/.cursorules/vue.md b/.cursor/rules/vue.md similarity index 100% rename from .cursorules/vue.md rename to .cursor/rules/vue.md diff --git a/.cursorules/design.md b/.cursorules/design.md deleted file mode 100644 index 4e98c5df48..0000000000 --- a/.cursorules/design.md +++ /dev/null @@ -1,63 +0,0 @@ -# 胶卷风格AI工具设计规范(二次创适用版) - - -## **核心风格定位** -「复古胶片暗调+现代代工具极简感」,以黑色基底为核心,叠加入胶片颗粒肌理,整体视觉克制而有质感,突出「剪辑工具的专业感」与「胶卷复古的氛围感」,避免与现有工具同质化。 - - -## **1. 颜色规范** -- **主色**: - - 背景:#0D0D0D(深黑,带1%青灰调,区别纯黑) - - 主功能色:#00B030(低饱和苔藓绿,用于按钮/选中态,与已知品牌色差异明显) -- **辅助色**: - - 交互蓝:#1A66E0(用于预览/保存等次级操作) - - 强调橙:#FF6A30(用于标记点/警告,低明度避免刺眼) -- **中性色**: - - 模块底:#1A1A1A(比背景亮5%,区分层级) - - 文本:#F2F2F2(正文)、#CCCCCC(次要文本) - - 边框:#333333(1px细线条,弱化割裂感) - - -## **2. 质感与阴影** -- **肌理**:全局叠加原创胶片颗粒(3%灰度噪点,随机生成,非真实胶卷扫描图) -- **阴影**: - - 卡片/模块:内阴影(0 2px 4px rgba(0,0,0,0.4)),无外阴影 - - 按钮hover:轻微发光(0 0 6px rgba(0,176,48,0.3),主色低饱和光晕) - - -## **3. 图标选型** -- **风格**:线性几何风,线条粗细1.5px,圆角2px -- **载体**:统一使用 SVG(图标文件与 SVG Sprite/Icon 组件),禁止使用位图作为图标 -- **禁用**:避免使用与知名剪辑工具高度相似的图标(如剪映、Pr的标志性符号) - - -## **4. 卡片规范** -- **形态**:圆角6px(非直角/大圆角),边框1px #333333 -- **内容区**:内边距16px,底部可加「胶片式参数条」(黑底白字小文本,如“1080p | 30fps”,纯装饰) -- **状态**: - - 活跃态:边框改为主色#00B030 - - hover态:背景色加深至#161616 - - -## **5. 布局规范** -- **整体结构**:顶部导航(高52px)+ 左侧功能栏(宽60px图标/200px展开)+ 主内容区(占比70%)+ 右侧参数面板(占比30%) -- **间距**:模块间margin 20px,元素内padding 12-16px,避免拥挤 -- **移动端**:左侧栏转为底部悬浮按钮组(4个核心功能+居中主按钮) - - -## **6. 标题与文本** -- **标题**:字体「Montserrat」(半粗体,20px),字间距0.5px,颜色#F2F2F2 -- **正文**:字体「Inter」(常规,14px),行高1.5,颜色#F2F2F2 -- **辅助文本**:字体「Inter」(常规,12px),颜色#CCCCCC - - -## **7. 设计提示词(供生成式设计/插画参考)** -- **总体风格**:复古胶片暗调、现代极简 UI、低饱和高级质感、专业剪辑工具氛围、控色节制 -- **质感**:微颗粒胶片噪点(3% 灰度随机)、内阴影层次、金属磨砂、磨光边缘 -- **配色**:深黑 #0D0D0D 背景、模块底 #1A1A1A、主色 #00B030、交互蓝 #1A66E0、强调橙 #FF6A30、细边界 #333333 -- **光影**:按钮 Hover 轻微发光(0 0 6px rgba(0,176,48,0.3))、卡片内阴影(inset 0 2px 4px rgba(0,0,0,0.4)) -- **形态**:圆角 6px、1px 细边、紧凑留白(内边距 12–16px,模块间距 20px) -- **图标**:线性几何、1.5px 描边、统一 SVG、避免品牌相似符号 -- **插画/装饰**:暗色渐变+噪点、胶片孔洞/标尺式细节可点缀,勿喧宾夺主 -- **可用性**:高对比可读性、色弱可访问、交互状态清晰(禁用/加载/选中) - diff --git a/frontend/api/README.md b/frontend/api/README.md new file mode 100644 index 0000000000..e96ccc7d6f --- /dev/null +++ b/frontend/api/README.md @@ -0,0 +1,129 @@ +# Mono 级别 API 架构 + +## 📁 目录结构 + +``` +frontend/api/ +├── axios/ +│ └── client.js # Mono 级别的 C 端 Axios 实例 +├── services/ +│ ├── tikhub.js # TikHub API 服务 +│ └── index.js # 服务统一导出 +└── README.md # 本文档 +``` + +## 🎯 设计理念 + +### 1. 分层清晰 +- **Mono 级别** (`frontend/api/`): 可在 monorepo 中所有应用复用的代码 +- **应用级别** (`frontend/app/web-gold/src/api/`): 应用特定的 API 封装 + +### 2. 模块化设计 +- **Axios 实例**: 统一的 HTTP 客户端配置 +- **API 服务**: 按功能模块组织(如 `TikHubService`) +- **Hooks**: 直接使用服务,无需全局注入 + +### 3. 人类可读 +- 清晰的命名和注释 +- 直观的导入路径 +- 易于理解和维护 + +## 🚀 使用方式 + +### 在 Mono 级别使用 + +```javascript +// 直接使用服务 +import { TikHubService } from '@gold/api/services' + +const result = await TikHubService.videoToCharacters({ + fileLinkList: ['https://example.com/audio.mp3'] +}) +``` + +### 在应用层使用 + +```javascript +// 使用应用层封装的服务 +import { CommonService } from '@/api/common' + +const result = await CommonService.videoToCharacters({ + fileLinkList: ['https://example.com/audio.mp3'] +}) +``` + +### 在 Hooks 中使用 + +```typescript +// useVoiceText 直接使用 TikHubService,无需配置 +import useVoiceText from '@gold/hooks/web/useVoiceText' + +const { getVoiceText } = useVoiceText() +const transcriptions = await getVoiceText([ + { audio_url: 'https://example.com/audio.mp3' } +]) +``` + +## 📦 添加新的 API 服务 + +### 1. 创建服务文件 + +```javascript +// frontend/api/services/my-service.js +import { clientAxios } from '@gold/api/axios/client' +import { API_BASE } from '@gold/config/api' + +const BASE_URL = API_BASE.MY_SERVICE || '' + +export const MyService = { + async getData(params) { + return await clientAxios.get(`${BASE_URL}/data`, { params }) + }, + + async createData(data) { + return await clientAxios.post(`${BASE_URL}/data`, data) + }, +} + +export default MyService +``` + +### 2. 导出服务 + +```javascript +// frontend/api/services/index.js +export { TikHubService } from './tikhub' +export { MyService } from './my-service' +``` + +### 3. 使用服务 + +```javascript +import { MyService } from '@gold/api/services' + +const data = await MyService.getData({ id: 1 }) +``` + +## 🔧 自定义 Axios 实例 + +如果需要创建自定义的 Axios 实例: + +```javascript +import { createClientAxios } from '@gold/api/axios/client' + +const customAxios = createClientAxios({ + baseURL: '/custom-api', + timeout: 60000, + on401: () => { + // 自定义 401 处理 + } +}) +``` + +## 📝 注意事项 + +1. **Mono 级别代码**应该保持通用,不依赖特定应用的逻辑 +2. **应用层代码**可以依赖应用特定的 store、组件等 +3. **服务模块**应该按功能划分,保持单一职责 +4. **Axios 实例**统一管理,避免重复配置 + diff --git a/frontend/api/axios/client.js b/frontend/api/axios/client.js new file mode 100644 index 0000000000..60da4858b6 --- /dev/null +++ b/frontend/api/axios/client.js @@ -0,0 +1,152 @@ +/** + * Mono 级别的 C 端 Axios 实例 + * 供 monorepo 中所有应用使用的统一 HTTP 客户端 + */ + +import axios from 'axios' +import { getAuthHeader, clearAllTokens } from '@gold/utils/token-manager' + +/** + * 不需要 token 的接口白名单 + */ +const WHITE_LIST = [ + '/auth/login', + '/auth/send-sms-code', + '/auth/sms-login', + '/auth/validate-sms-code', + '/auth/register', + '/auth/reset-password', + '/auth/refresh-token', +] + +/** + * 检查 URL 是否在白名单中 + */ +function isInWhiteList(url) { + if (!url) return false + return WHITE_LIST.some((path) => url.includes(path)) +} + +/** + * 处理 401 未授权错误 + */ +let isHandling401 = false +function handle401Error() { + if (isHandling401) return + + isHandling401 = true + + try { + clearAllTokens() + } catch (e) { + console.error('清空 token 失败:', e) + } + + // 延迟重置标志 + setTimeout(() => { + isHandling401 = false + }, 2000) +} + +/** + * 创建 C 端 Axios 实例 + * @param {Object} options - 配置选项 + * @param {string} options.baseURL - 基础 URL + * @param {number} options.timeout - 超时时间(毫秒) + * @param {Function} options.on401 - 401 错误处理函数 + * @param {Function} options.on403 - 403 错误处理函数 + * @returns {AxiosInstance} Axios 实例 + */ +export function createClientAxios(options = {}) { + const { + baseURL = '/', + timeout = 180000, + on401 = handle401Error, + on403 = null, + } = options + + const client = axios.create({ + baseURL, + timeout, + }) + + // 请求拦截器 + client.interceptors.request.use((config) => { + // 添加 tenant-id + const tenantId = + (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TENANT_ID) || + (typeof process !== 'undefined' && process.env?.VITE_TENANT_ID) || + '1' + + if (tenantId) { + config.headers['tenant-id'] = tenantId + } + + // 添加 Authorization header + const needToken = config.headers?.isToken !== false && !isInWhiteList(config.url || '') + if (needToken) { + const authHeader = getAuthHeader() + if (authHeader) { + config.headers.Authorization = authHeader + } + } + + return config + }) + + // 响应拦截器 + client.interceptors.response.use( + (response) => { + const data = response.data + + // 检查业务状态码 + if (data && typeof data.code === 'number') { + if (data.code === 0 || data.code === 200) { + return data + } + + // 处理 401 + if (data.code === 401 && typeof on401 === 'function') { + on401() + } + + // 处理 403(业务状态码) + if (data.code === 403 && typeof on403 === 'function') { + on403() + } + + // 抛出业务错误 + const error = new Error(data?.message || data?.msg || '请求失败') + error.code = data?.code + error.data = data + return Promise.reject(error) + } + + return data + }, + (error) => { + // 处理 HTTP 401 + if (error.response?.status === 401 && typeof on401 === 'function') { + on401() + } + + // 处理 HTTP 403 + if (error.response?.status === 403 && typeof on403 === 'function') { + on403() + } + + return Promise.reject(error) + } + ) + + return client +} + +/** + * 默认导出的 C 端 Axios 实例 + * 可在应用层覆盖配置 + */ +export const clientAxios = createClientAxios() + +export default clientAxios + diff --git a/frontend/api/services.js b/frontend/api/services.js deleted file mode 100644 index bcc54ea270..0000000000 --- a/frontend/api/services.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * 公共 API 服务 - * 封装可在 monorepo 各个应用中复用的 API 调用 - * - * 使用方式: - * import { createApiService } from '@gold/config/api/services' - * - * const apiService = createApiService({ - * http: axiosInstance, - * getAuthHeader: () => 'Bearer token', - * baseUrl: API_BASE.TIKHUB_APP - * }) - * - * await apiService.videoToCharacters({ fileLinkList: [...] }) - */ - -import { API_BASE } from '@gold/config/api' - -/** - * 创建 API 服务实例 - * @param {Object} options - 配置选项 - * @param {Object} options.http - HTTP 客户端实例(如 axios) - * @param {Function} options.getAuthHeader - 获取 Authorization header 的函数 - * @param {string} options.baseUrl - API 基础 URL(可选,默认使用 TIKHUB_APP) - * @returns {Object} API 服务对象 - */ -export function createApiService(options = {}) { - const { http, getAuthHeader, baseUrl } = options - - if (!http) { - throw new Error('createApiService: http 实例是必需的') - } - - // 确定 API 基础路径 - // 如果没有提供 baseUrl,尝试使用 TIKHUB_APP 或 TIKHUB - const apiBaseUrl = baseUrl || API_BASE.TIKHUB_APP || API_BASE.TIKHUB || '' - - /** - * 视频转字符(音频转文字) - * @param {Object} data - 请求数据 - * @param {string[]} data.fileLinkList - 音频文件链接列表 - * @returns {Promise} 响应数据 - */ - async function videoToCharacters(data) { - const url = `${apiBaseUrl}/videoToCharacters2` - - const headers = { - 'Content-Type': 'application/json', - } - - // 添加 Authorization header(如果提供了 getAuthHeader 函数) - if (getAuthHeader) { - const authHeader = getAuthHeader() - if (authHeader) { - headers.Authorization = authHeader - } - } - - // 获取 tenant-id(从环境变量或默认值) - const tenantId = - (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TENANT_ID) || - (typeof process !== 'undefined' && process.env?.VITE_TENANT_ID) || - '1' - - if (tenantId) { - headers['tenant-id'] = tenantId - } - - return await http.post(url, data, { headers }) - } - - /** - * 调用工作流 - * @param {Object} data - 请求数据 - * @returns {Promise} 响应数据 - */ - async function callWorkflow(data) { - const url = `${apiBaseUrl}/callWorkflow` - - const headers = { - 'Content-Type': 'application/json', - } - - if (getAuthHeader) { - const authHeader = getAuthHeader() - if (authHeader) { - headers.Authorization = authHeader - } - } - - const tenantId = - (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TENANT_ID) || - (typeof process !== 'undefined' && process.env?.VITE_TENANT_ID) || - '1' - - if (tenantId) { - headers['tenant-id'] = tenantId - } - - return await http.post(url, data, { headers }) - } - - return { - videoToCharacters, - callWorkflow, - } -} - -/** - * 默认导出(便于直接使用) - */ -export default createApiService - diff --git a/frontend/api/services/index.js b/frontend/api/services/index.js new file mode 100644 index 0000000000..1e33117ec0 --- /dev/null +++ b/frontend/api/services/index.js @@ -0,0 +1,11 @@ +/** + * API 服务统一导出 + * 按功能模块组织,便于维护和扩展 + */ + +export { TikHubService } from './tikhub' + +// 可以继续添加其他服务模块 +// export { UserService } from './user' +// export { ChatService } from './chat' + diff --git a/frontend/api/services/tikhub.js b/frontend/api/services/tikhub.js new file mode 100644 index 0000000000..744e796cb1 --- /dev/null +++ b/frontend/api/services/tikhub.js @@ -0,0 +1,43 @@ +/** + * TikHub API 服务 + * 封装 TikHub 相关的 API 调用 + */ + +import { clientAxios } from '@gold/api/axios/client' +import { API_BASE } from '@gold/config/api' + +/** + * TikHub API 基础路径 + */ +const BASE_URL = API_BASE.TIKHUB_APP || API_BASE.TIKHUB || '' + +/** + * TikHub API 服务 + */ +export const TikHubService = { + /** + * 视频转字符(音频转文字) + * @param {Object} params - 请求参数 + * @param {string[]} params.fileLinkList - 音频文件链接列表 + * @returns {Promise<{ data: string }>} 响应数据 + */ + async videoToCharacters(params) { + const { fileLinkList } = params + + return await clientAxios.post(`${BASE_URL}/videoToCharacters2`, { + fileLinkList, + }) + }, + + /** + * 调用工作流 + * @param {Object} data - 请求数据 + * @returns {Promise} 响应数据 + */ + async callWorkflow(data) { + return await clientAxios.post(`${BASE_URL}/callWorkflow`, data) + }, +} + +export default TikHubService + diff --git a/frontend/app/web-gold/src/api/README.md b/frontend/app/web-gold/src/api/README.md deleted file mode 100644 index f9b2b7e96d..0000000000 --- a/frontend/app/web-gold/src/api/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# API 统一管理说明 - -## 📁 目录结构 - -``` -api/ -├── config.js # API 基础配置(统一管理所有基础 URL) -├── index.js # 统一导出入口(所有 API 服务从这里导出) -├── http.js # Axios 实例和拦截器 -├── auth.js # 认证相关 API -├── chat.js # 聊天相关 API -├── common.js # 通用服务 API -└── tikhub/ # TikHub 相关 API - ├── index.js - ├── tikhub.js - └── types.js -``` - -## 🚀 使用方式 - -### 方式一:从统一入口导入(推荐) - -```javascript -// 导入所有 API -import { ChatMessageApi, AuthApi, CommonService, TikhubService } from '@/api' - -// 或按需导入 -import { ChatMessageApi } from '@/api' -import { AuthApi } from '@/api' -``` - -### 方式二:从具体文件导入(兼容旧代码) - -```javascript -// 仍然支持原有的导入方式 -import { ChatMessageApi } from '@/api/chat' -import { CommonService } from '@/api/common' -``` - -### 方式三:使用配置工具函数 - -```javascript -import { getApiUrl, API_BASE } from '@/api/config' - -// 获取完整 API URL -const url = getApiUrl('ADMIN_AI', '/chat/conversation/create-my') -// 结果: /admin-api/ai/chat/conversation/create-my - -// 直接使用配置 -const baseUrl = API_BASE.ADMIN_AI -``` - -## 📝 API 配置说明 - -### config.js - -所有 API 基础 URL 统一在 `config.js` 中管理: - -```javascript -export const API_BASE = { - ADMIN: '/admin-api', // 管理后台基础路径 - APP: '/app-api', // 会员端基础路径 - ADMIN_AI: '/admin-api/ai', // AI 模块(管理后台) - APP_MEMBER: '/app-api/member', // 会员模块 - TIKHUB: '/webApi/admin-api/ai/tikHup', // TikHub(管理后台) - TIKHUB_APP: '/app-api/api/tikHup', // TikHub(会员端) -} -``` - -### 添加新的 API 模块 - -1. 在 `config.js` 中添加新的基础路径: -```javascript -export const API_BASE = { - // ... 现有配置 - NEW_MODULE: `${BASE_URL}/admin-api/new-module`, -} -``` - -2. 创建新的 API 文件(如 `new-module.js`): -```javascript -import request from '@/api/http' -import { API_BASE } from '@/api/config' - -const BASE = API_BASE.NEW_MODULE - -export const NewModuleApi = { - getList: () => request.get(`${BASE}/list`), - create: (data) => request.post(`${BASE}/create`, data), -} -``` - -3. 在 `index.js` 中导出: -```javascript -export { NewModuleApi } from './new-module' -``` - -## 🔧 HTTP 实例 - -所有 API 都使用统一的 HTTP 实例(`http.js`),已配置: -- ✅ 自动 Token 注入 -- ✅ 统一错误处理 -- ✅ 请求/响应拦截器 -- ✅ 白名单机制(无需 Token 的接口) - -## 📌 注意事项 - -1. **基础 URL 配置**:所有 API 的基础 URL 都应该在 `config.js` 中定义,不要在业务文件中硬编码 -2. **统一导出**:新增 API 服务后,记得在 `index.js` 中导出 -3. **向后兼容**:保持原有的导入方式仍然可用,方便逐步迁移 - -## 🎯 最佳实践 - -1. **使用统一入口**:优先使用 `@/api` 统一导入 -2. **配置集中管理**:所有 URL 配置都在 `config.js` -3. **类型安全**:使用 TypeScript 时,可以为 API 添加类型定义 -4. **错误处理**:利用 HTTP 拦截器统一处理错误 - diff --git a/frontend/app/web-gold/src/api/chat.js b/frontend/app/web-gold/src/api/chat.js index 4303022e0e..2fd6065558 100644 --- a/frontend/app/web-gold/src/api/chat.js +++ b/frontend/app/web-gold/src/api/chat.js @@ -4,7 +4,8 @@ import { getAccessToken } from '@/utils/auth' // 使用公共配置 import { API_BASE } from '@gold/config/api' -const SERVER_BASE_AI = API_BASE.ADMIN_AI +// C 端使用 APP_AI,如果不存在则回退到 ADMIN_AI +const SERVER_BASE_AI = API_BASE.APP_AI || API_BASE.ADMIN_AI diff --git a/frontend/app/web-gold/src/api/common.js b/frontend/app/web-gold/src/api/common.js index 0800382238..43d6b84376 100644 --- a/frontend/app/web-gold/src/api/common.js +++ b/frontend/app/web-gold/src/api/common.js @@ -1,36 +1,41 @@ -import http from '@/api/http' +/** + * 应用层 API 服务 + * 封装应用特定的 API 调用,使用 mono 级别的服务 + */ + import { fetchEventSource } from '@microsoft/fetch-event-source' import { getAuthHeader } from '@gold/utils/token-manager' -// 使用公共配置和 API 服务创建器 +import { TikHubService } from '@gold/api/services' import { API_BASE } from '@gold/config/api' -import { createApiService } from '@gold/api/services' -// 初始化公共 hook 的 API 服务 -import { setApiService } from '@gold/hooks/web/useVoiceText' -// 使用本地代理前缀 /tikhub,开发环境通过 Vite 代理到 https://api.tikhub.io -// 注意:API_BASE.TIKHUB 不存在,应该使用 TIKHUB_APP -const SERVER_BASE = API_BASE.TIKHUB_APP || API_BASE.TIKHUB || '' - -// 创建公共 API 服务实例 -const apiService = createApiService({ - http, - getAuthHeader, - baseUrl: SERVER_BASE, -}) - -// 设置全局 API 服务(供 useVoiceText hook 使用) -setApiService(apiService) +/** + * TikHub API 基础路径 + */ +const TIKHUB_BASE = API_BASE.TIKHUB_APP || API_BASE.TIKHUB || '' +/** + * 应用层通用服务 + */ export const CommonService = { + /** + * 视频转字符(音频转文字) + * 直接使用 mono 级别的 TikHub 服务 + */ videoToCharacters(data) { - return apiService.videoToCharacters(data) + return TikHubService.videoToCharacters(data) }, + + /** + * 调用工作流 + */ callWorkflow(data) { - return apiService.callWorkflow(data) + return TikHubService.callWorkflow(data) }, - // 流式调用 workflow - callWorkflowStream: async (options) => { + /** + * 流式调用工作流(SSE) + */ + async callWorkflowStream(options) { const { data, ctrl, @@ -42,9 +47,9 @@ export const CommonService = { const authHeader = getAuthHeader() let retryCount = 0 - const maxRetries = 0 // 禁用自动重试 + const maxRetries = 0 - return fetchEventSource(`${SERVER_BASE}/callWorkflow`, { + return fetchEventSource(`${TIKHUB_BASE}/callWorkflow`, { method: 'post', headers: { 'Content-Type': 'application/json', @@ -57,18 +62,15 @@ export const CommonService = { retryCount++ console.error('SSE错误,重试次数:', retryCount, err) - // 调用自定义错误处理 if (typeof onError === 'function') { onError(err) } - // 超过最大重试次数,停止重连 if (retryCount > maxRetries) { - throw err // 抛出错误,停止自动重连 + throw err } }, onclose: () => { - // 调用自定义关闭处理 if (typeof onClose === 'function') { onClose() } diff --git a/frontend/app/web-gold/src/api/config.js b/frontend/app/web-gold/src/api/config.js deleted file mode 100644 index 4c1768549c..0000000000 --- a/frontend/app/web-gold/src/api/config.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * API 基础配置 - * 统一管理所有 API 的基础 URL - * - * 注意:此文件已迁移到公共模块 @gold/config/api - * 为了保持向后兼容,这里重新导出公共配置 - * 新代码建议直接使用 @gold/config/api - */ - -// 从公共模块导入 -export { API_BASE, getApiUrl } from '@gold/config/api' - - - diff --git a/frontend/app/web-gold/src/api/example.js b/frontend/app/web-gold/src/api/example.js deleted file mode 100644 index eac71f7c64..0000000000 --- a/frontend/app/web-gold/src/api/example.js +++ /dev/null @@ -1,72 +0,0 @@ -/** - * API 使用示例 - * 此文件仅作为参考,展示如何使用统一的 API 管理 - */ - -// ========== 方式一:从统一入口导入(推荐) ========== -import { - ChatMessageApi, - CommonService, - TikhubService, - API_BASE, - getApiUrl, - http -} from '@/api' - -// 使用示例 -async function example1() { - // 使用 ChatMessageApi - const conversationId = await ChatMessageApi.createChatConversationMy({ - roleId: 1 - }) - - // 使用 CommonService - const result = await CommonService.videoToCharacters({ videoUrl: 'xxx' }) - - // 使用 TikhubService - await TikhubService.postTikHup({ - type: 'DOUYIN_WEB_HOT_SEARCH', - methodType: 'GET', - urlParams: { keyword: '测试' } - }) - - // 使用配置 - const baseUrl = API_BASE.ADMIN_AI - const fullUrl = getApiUrl('ADMIN_AI', '/chat/conversation/create-my') - - // 直接使用 http 实例 - await http.get('/some-endpoint') -} - -// ========== 方式二:从具体文件导入(兼容旧代码) ========== -import { ChatMessageApi } from '@/api/chat' -import { CommonService } from '@/api/common' -// 使用公共配置 -import { API_BASE } from '@gold/config/api' - -async function example2() { - // 原有方式仍然可用 - await ChatMessageApi.createChatConversationMy({ roleId: 1 }) -} - -// ========== 方式三:按需导入认证 API ========== -import { - loginBySms, - sendSmsCode, - SMS_SCENE -} from '@/api' - -async function example3() { - // 发送验证码 - await sendSmsCode('13800138000', SMS_SCENE.MEMBER_LOGIN) - - // 短信登录 - await loginBySms('13800138000', '123456') -} - -export default { - example1, - example2, - example3 -} - diff --git a/frontend/app/web-gold/src/api/http.js b/frontend/app/web-gold/src/api/http.js index c2b3cf7781..dd5ff4a6be 100644 --- a/frontend/app/web-gold/src/api/http.js +++ b/frontend/app/web-gold/src/api/http.js @@ -1,126 +1,168 @@ -import axios from 'axios' +/** + * 应用层 HTTP 客户端 + * 使用 mono 级别的 axios 实例,添加应用特定的 401 处理 + */ + import { message } from 'ant-design-vue' -import { getAuthHeader, clearAllTokens } from '@gold/utils/token-manager' +import { clearAllTokens, getRefreshToken } from '@gold/utils/token-manager' import { useUserStore } from '@/stores/user' +import { createClientAxios } from '@gold/api/axios/client' +import { refreshToken } from '@/api/auth' + +// 刷新 token 的状态管理 +let isRefreshing = false +let refreshPromise = null /** - * 不需要 token 的接口白名单 - * 支持完整路径匹配或路径包含匹配 + * 处理 403 禁止访问错误(应用层特定逻辑) + * 先尝试刷新 token,如果失败或没有 refresh token 才提示用户 */ -const WHITE_LIST = [ - '/auth/login', // 密码登录 - '/auth/send-sms-code', // 发送验证码 - '/auth/sms-login', // 短信登录 - '/auth/validate-sms-code', // 验证验证码 - '/auth/register', // 注册(如果有) - '/auth/reset-password', // 重置密码 - '/auth/refresh-token', // 刷新token(可选,根据后端要求) -] - -/** - * 检查 URL 是否在白名单中 - * @param {string} url - 请求 URL - * @returns {boolean} - */ -function isInWhiteList(url) { - if (!url) return false - return WHITE_LIST.some((path) => url.includes(path)) -} - - - -// 创建 axios 实例 -const http = axios.create({ - baseURL: '/', - timeout: 180000, // 3分钟 -}) - -// 请求拦截:自动注入 Token -http.interceptors.request.use((config) => { - // 检查是否需要 token(不在白名单中且未显式设置 isToken = false) - const needToken = config.headers?.isToken !== false && !isInWhiteList(config.url || '') - config.headers['tenant-id'] = import.meta.env.VITE_TENANT_ID - if (needToken) { - // 使用统一的 token 管理器获取 header - const authHeader = getAuthHeader() - if (authHeader) { - config.headers.Authorization = authHeader - } - } - - // 允许跨域第三方域名,使用绝对地址时保持原样 - return config -}) - -http.interceptors.response.use( - (resp) => { - const data = resp.data - // 检查响应数据中的 code 字段 - if (data && typeof data.code === 'number' && (data.code === 0 || data.code === 200)) { - return data - } else { - // code 不为 0 时,检查是否为401 - if (data && typeof data.code === 'number' && data.code === 401) { - handle401Error() - } - // code 不为 0 时,抛出错误 - const error = new Error(data?.message || data?.msg || '请求失败') - error.code = data?.code - error.data = data - return Promise.reject(error) - } - }, - (error) => { - // 处理 HTTP 状态码 401 - if (error.response && error.response.status === 401) { - handle401Error() - } - // 统一错误处理:输出关键信息,便于排查 403 等问题 - return Promise.reject(error) - } -) - -/** - * 处理 401 未授权错误 - * 清空 token 并退出登录 - * - * 注意:使用防抖机制避免多个请求同时401时重复处理 - */ -function handle401Error() { - // 避免重复处理(防止多个请求同时401导致多次调用) - if (handle401Error.processed) { +async function handleApp403Error() { + // 避免重复处理 + if (handleApp403Error.processed) { return } - handle401Error.processed = true + handleApp403Error.processed = true - // 1. 清空所有 token try { - clearAllTokens() // 统一使用 token-manager 的清空函数 + // 检查是否有 refresh token + const refreshTokenValue = getRefreshToken() + + if (refreshTokenValue) { + // 如果有 refresh token,尝试刷新 + try { + // 如果正在刷新,等待刷新完成 + if (isRefreshing && refreshPromise) { + await refreshPromise + handleApp403Error.processed = false + return + } + + // 开始刷新 token + isRefreshing = true + refreshPromise = refreshToken() + + await refreshPromise + + // 刷新成功,重置状态 + isRefreshing = false + refreshPromise = null + handleApp403Error.processed = false + + // 刷新成功,不提示用户(静默刷新) + return + } catch (refreshError) { + // 刷新失败,清除状态 + isRefreshing = false + refreshPromise = null + console.error('刷新 token 失败:', refreshError) + + // 刷新失败才提示用户 + message.warning('登录状态已过期,请重新登录', 3) + handleApp403Error.processed = false + return + } + } else { + // 没有 refresh token,提示用户 + message.warning('登录状态已过期,请重新登录', 3) + handleApp403Error.processed = false + } } catch (e) { - console.error('清空 token 失败:', e) + console.error('处理 403 错误失败:', e) + handleApp403Error.processed = false } - - // 2. 退出登录状态(清空用户信息) - try { - const userStore = useUserStore() - // logout() 会清空用户信息和本地存储 - userStore.logout() - } catch (e) { - console.error('退出登录失败:', e) - } - - // 3. 提示用户(延迟显示,避免在清空过程中显示) - setTimeout(() => { - message.warning('登录已过期,请重新登录', 3) - }, 100) - - // 4. 延迟重置标志,避免短时间内重复处理 - setTimeout(() => { - handle401Error.processed = false - }, 2000) } +/** + * 处理 401 未授权错误(应用层特定逻辑) + * 先尝试刷新 token,如果失败或没有 refresh token 才退出登录 + */ +async function handleApp401Error() { + // 避免重复处理 + if (handleApp401Error.processed) { + return + } + + handleApp401Error.processed = true + + try { + // 检查是否有 refresh token + const refreshTokenValue = getRefreshToken() + + if (refreshTokenValue) { + // 如果有 refresh token,尝试刷新 + try { + // 如果正在刷新,等待刷新完成 + if (isRefreshing && refreshPromise) { + await refreshPromise + handleApp401Error.processed = false + return + } + + // 开始刷新 token + isRefreshing = true + refreshPromise = refreshToken() + + await refreshPromise + + // 刷新成功,重置状态 + isRefreshing = false + refreshPromise = null + handleApp401Error.processed = false + + // 刷新成功,不提示用户(静默刷新) + return + } catch (refreshError) { + // 刷新失败,清除状态 + isRefreshing = false + refreshPromise = null + console.error('刷新 token 失败:', refreshError) + + // 继续执行退出登录逻辑 + } + } + + // 没有 refresh token 或刷新失败,执行退出登录 + try { + clearAllTokens() + } catch (e) { + console.error('清空 token 失败:', e) + } + + try { + const userStore = useUserStore() + userStore.logout() + } catch (e) { + console.error('退出登录失败:', e) + } + + setTimeout(() => { + message.warning('登录已过期,请重新登录', 3) + }, 100) + + } catch (e) { + console.error('处理 401 错误失败:', e) + } finally { + setTimeout(() => { + handleApp401Error.processed = false + }, 2000) + } +} + +/** + * 创建应用层 HTTP 客户端 + * 基于 mono 级别的 axios 实例,添加应用特定的错误处理 + */ +const http = createClientAxios({ + baseURL: '/', + timeout: 180000, + on401: handleApp401Error, + on403: handleApp403Error, +}) + +// 注意:403 处理已在 createClientAxios 的响应拦截器中通过 on403 回调处理 + export default http diff --git a/frontend/app/web-gold/src/api/index.js b/frontend/app/web-gold/src/api/index.js index d98482d424..e5680f4e6b 100644 --- a/frontend/app/web-gold/src/api/index.js +++ b/frontend/app/web-gold/src/api/index.js @@ -3,9 +3,6 @@ * 所有 API 服务都从这里导出,方便统一管理和使用 */ -// 配置 -export { default as API_BASE, getApiUrl } from './config' - // HTTP 实例 export { default as http, default as request } from './http' @@ -15,6 +12,9 @@ export * from './auth' // 聊天相关 API export { ChatMessageApi } from './chat' +// 用户提示词 API +export { UserPromptApi } from './userPrompt' + // 通用服务 API export { CommonService } from './common' export { default as CommonServiceDefault } from './common' @@ -23,20 +23,3 @@ export { default as CommonServiceDefault } from './common' export { TikhubService, default as TikhubServiceDefault } from './tikhub' export { InterfaceType, MethodType, InterfaceUrlMap, ParamType } from './tikhub/types' -/** - * 统一导出所有 API 服务(便于按需导入) - */ -export default { - // 配置 - config: () => import('./config'), - - // HTTP 实例 - http: () => import('./http'), - - // API 服务 - auth: () => import('./auth'), - chat: () => import('./chat'), - common: () => import('./common'), - tikhub: () => import('./tikhub'), -} - diff --git a/frontend/app/web-gold/src/api/tikhub.js b/frontend/app/web-gold/src/api/tikhub.js deleted file mode 100644 index 73ac88d3b1..0000000000 --- a/frontend/app/web-gold/src/api/tikhub.js +++ /dev/null @@ -1,5 +0,0 @@ -// TikHub API 统一导出入口 -export { TikhubService } from './tikhub/tikhub.js' -export { default } from './tikhub/tikhub.js' -export { InterfaceType, MethodType, InterfaceUrlMap, ParamType } from './tikhub/types.js' - diff --git a/frontend/app/web-gold/src/api/tikhub/README.md b/frontend/app/web-gold/src/api/tikhub/README.md deleted file mode 100644 index b1d3f61347..0000000000 --- a/frontend/app/web-gold/src/api/tikhub/README.md +++ /dev/null @@ -1,180 +0,0 @@ -# TikHub API 模块 - -本模块提供了统一的 TikHub 接口调用中间层,支持多种平台的 API 接口。 - -## 目录结构 - -``` -tikhub/ -├── types.js # 枚举定义:InterfaceType、MethodType -├── tikhub.js # 核心服务类 TikhubService -├── index.js # 统一导出入口 -├── example.js # 使用示例 -└── README.md # 本文档 -``` - -## 快速开始 - -### 1. 导入模块 - -```javascript -import TikhubService, { InterfaceType, MethodType } from '@/api/tikhub' -``` - -### 2. 调用接口 - -```javascript -// 调用抖音热门搜索接口 -const response = await TikhubService.postTikHup( - InterfaceType.DOUYIN_WEB_HOT_SEARCH, // 接口类型 - MethodType.GET, // HTTP 方法 - { keyword: '测试关键词' } // 实际接口参数 -) -``` - -## API 说明 - -### TikhubService.postTikHup(type, methodType, urlParams) - -统一调用 TikHub 接口的中间层方法。 - -**参数:** -- `type` (String) - 接口类型,使用 `InterfaceType` 枚举值 -- `methodType` (String) - HTTP 方法类型,使用 `MethodType` 枚举值 -- `urlParams` (Object|String) - 实际接口的参数 - -**返回:** -- Promise - axios 响应对象 - -**示例:** - -```javascript -// 获取小红书热门榜单 -await TikhubService.postTikHup( - InterfaceType.XIAOHONGSHU_WEB_HOT_LIST, - MethodType.GET, - { page: 1, page_size: 20 } -) - -// 搜索抖音内容 -await TikhubService.postTikHup( - InterfaceType.DOUYIN_GENERAL_SEARCH_V4, - MethodType.POST, - { - keyword: '热门内容', - sort_type: 0, - publish_time: 0, - } -) -``` - -## 枚举说明 - -### InterfaceType - 接口类型 - -支持的所有接口类型: - -| 枚举值 | 说明 | -|--------|------| -| `XIAOHONGSHU_USER_INFO` | 小红书 - 获取用户信息 | -| `DOUYIN_WEB_USER_POST_VIDEOS` | 抖音 - 网页端获取用户发布的视频 | -| `DOUYIN_APP_USER_POST_VIDEOS` | 抖音 - APP端V3获取用户发布的视频 | -| `DOUYIN_APP_GENERAL_SEARCH` | 抖音 - APP端V3通用搜索结果 | -| `DOUYIN_SEARCH_SUGGEST` | 抖音 - 搜索建议 | -| `DOUYIN_GENERAL_SEARCH_V4` | 抖音 - 通用搜索V4 | -| `XIAOHONGSHU_WEB_HOT_LIST` | 小红书 - 网页端V2热门榜单 | -| `DOUYIN_WEB_HOT_SEARCH` | 抖音 - 网页端热门搜索结果 | -| `KUAISHOU_WEB_HOT_LIST` | 快手 - 网页端热门榜单V2 | -| `BILIBILI_WEB_POPULAR` | B站 - 网页端流行内容 | -| `WEIBO_WEB_HOT_SEARCH` | 微博 - 网页端V2热门搜索指数 | -| `DOUYIN_WEB_GENERAL_SEARCH` | 抖音 - 网页端通用搜索结果 | - -### MethodType - HTTP 方法 - -| 枚举值 | 说明 | -|--------|------| -| `GET` | GET 请求 | -| `POST` | POST 请求 | -| `PUT` | PUT 请求 | -| `DELETE` | DELETE 请求 | -| `PATCH` | PATCH 请求 | - -## 使用示例 - -详细的使用示例请参考 `example.js` 文件。 - -### 示例 1:获取抖音热门搜索 - -```javascript -import TikhubService, { InterfaceType, MethodType } from '@/api/tikhub' - -async function getDouyinHotSearch() { - try { - const response = await TikhubService.postTikHup( - InterfaceType.DOUYIN_WEB_HOT_SEARCH, - MethodType.GET, - { keyword: '测试关键词' } - ) - return response - } catch (error) { - console.error('调用失败:', error) - throw error - } -} -``` - -### 示例 2:获取用户发布的视频 - -```javascript -async function getUserVideos() { - return await TikhubService.postTikHup( - InterfaceType.DOUYIN_WEB_USER_POST_VIDEOS, - MethodType.GET, - { - sec_user_id: 'MS4wLjABAAAxxxxxxxx', - count: 20, - max_cursor: 0, - } - ) -} -``` - -## 错误处理 - -```javascript -try { - const response = await TikhubService.postTikHup( - InterfaceType.DOUYIN_WEB_HOT_SEARCH, - MethodType.GET, - { keyword: '测试' } - ) - console.log('成功:', response) -} catch (error) { - if (error.message.includes('无效的接口类型')) { - console.error('接口类型错误') - } else { - console.error('请求失败:', error.message) - } -} -``` - -## 后端接口格式 - -本模块会调用后端接口 `/webApi/admin-api/ai/tikHup/post_tik_hup`,传递的参数格式为: - -```json -{ - "interface_type": "8", - "method_type": "GET", - "platform_url": "https://api.tikhub.io/api/v1/douyin/web/fetch_hot_search_result", - "url_params": { "keyword": "测试" } -} -``` - -## 注意事项 - -1. 所有接口类型必须使用 `InterfaceType` 枚举,不能使用字符串数字 -2. HTTP 方法必须使用 `MethodType` 枚举 -3. `urlParams` 可以是对象或字符串,取决于实际接口的要求 -4. 后端会根据 `interface_type` 从数据库中获取对应的 `platform_token` - diff --git a/frontend/app/web-gold/src/api/tikhub/index.js b/frontend/app/web-gold/src/api/tikhub/index.js index 880159eec8..e0f703d4b2 100644 --- a/frontend/app/web-gold/src/api/tikhub/index.js +++ b/frontend/app/web-gold/src/api/tikhub/index.js @@ -1,7 +1,6 @@ /** * TikHub API 统一导出 */ -export { TikhubService } from './tikhub.js' -export { default } from './tikhub.js' +export { TikhubService, default } from './tikhub.js' export { InterfaceType, MethodType, InterfaceUrlMap, ParamType } from './types.js' diff --git a/frontend/app/web-gold/src/api/userPrompt.js b/frontend/app/web-gold/src/api/userPrompt.js new file mode 100644 index 0000000000..27e34bb375 --- /dev/null +++ b/frontend/app/web-gold/src/api/userPrompt.js @@ -0,0 +1,68 @@ +import http from '@/api/http' +import { API_BASE } from '@gold/config/api' + +// C 端使用 APP_AI,如果不存在则回退到 ADMIN_AI +// 后端路径是 /app-api/ai/user-prompt,参考 chat API 的实现 +const SERVER_BASE_AI = API_BASE.APP_AI || API_BASE.ADMIN_AI + +/** + * 用户提示词 API + */ +export const UserPromptApi = { + /** + * 创建用户提示词 + * @param {Object} data - 提示词数据 + * @returns {Promise} 响应数据 + */ + createUserPrompt: async (data) => { + return await http.post(`${SERVER_BASE_AI}/user-prompt/create`, data) + }, + + /** + * 更新用户提示词 + * @param {Object} data - 提示词数据 + * @returns {Promise} 响应数据 + */ + updateUserPrompt: async (data) => { + return await http.put(`${SERVER_BASE_AI}/user-prompt/update`, data) + }, + + /** + * 分页查询用户提示词 + * @param {Object} params - 查询参数 + * @returns {Promise} 响应数据 + */ + getUserPromptPage: async (params) => { + return await http.get(`${SERVER_BASE_AI}/user-prompt/page`, { params }) + }, + + /** + * 获取单个用户提示词 + * @param {Number} id - 提示词ID + * @returns {Promise} 响应数据 + */ + getUserPrompt: async (id) => { + return await http.get(`${SERVER_BASE_AI}/user-prompt/get`, { params: { id } }) + }, + + /** + * 删除用户提示词 + * @param {Number} id - 提示词ID + * @returns {Promise} 响应数据 + */ + deleteUserPrompt: async (id) => { + return await http.delete(`${SERVER_BASE_AI}/user-prompt/delete`, { params: { id } }) + }, + + /** + * 批量删除用户提示词 + * @param {Array} ids - 提示词ID列表 + * @returns {Promise} 响应数据 + */ + deleteUserPromptList: async (ids) => { + return await http.delete(`${SERVER_BASE_AI}/user-prompt/delete-list`, { params: { ids } }) + }, +} + +export default UserPromptApi + diff --git a/frontend/app/web-gold/src/components/ChatMessageRenderer.vue b/frontend/app/web-gold/src/components/ChatMessageRenderer.vue index 3e4109a449..7fc24cd261 100644 --- a/frontend/app/web-gold/src/components/ChatMessageRenderer.vue +++ b/frontend/app/web-gold/src/components/ChatMessageRenderer.vue @@ -3,7 +3,7 @@ diff --git a/frontend/app/web-gold/src/views/content-style/Copywriting.vue b/frontend/app/web-gold/src/views/content-style/Copywriting.vue index 17347ede8e..10c2707204 100644 --- a/frontend/app/web-gold/src/views/content-style/Copywriting.vue +++ b/frontend/app/web-gold/src/views/content-style/Copywriting.vue @@ -1,13 +1,16 @@ + + + + + diff --git a/frontend/app/web-gold/src/views/content-style/components/BenchmarkForm.vue b/frontend/app/web-gold/src/views/content-style/components/BenchmarkForm.vue new file mode 100644 index 0000000000..6ecad26d21 --- /dev/null +++ b/frontend/app/web-gold/src/views/content-style/components/BenchmarkForm.vue @@ -0,0 +1,126 @@ + + + + + + diff --git a/frontend/app/web-gold/src/views/content-style/components/BenchmarkTable.vue b/frontend/app/web-gold/src/views/content-style/components/BenchmarkTable.vue new file mode 100644 index 0000000000..8795d55e47 --- /dev/null +++ b/frontend/app/web-gold/src/views/content-style/components/BenchmarkTable.vue @@ -0,0 +1,232 @@ + + + + + + diff --git a/frontend/app/web-gold/src/views/content-style/components/ExpandedRowContent.vue b/frontend/app/web-gold/src/views/content-style/components/ExpandedRowContent.vue new file mode 100644 index 0000000000..e620191069 --- /dev/null +++ b/frontend/app/web-gold/src/views/content-style/components/ExpandedRowContent.vue @@ -0,0 +1,195 @@ + + + + + + diff --git a/frontend/app/web-gold/src/views/content-style/components/SavePromptModal.vue b/frontend/app/web-gold/src/views/content-style/components/SavePromptModal.vue new file mode 100644 index 0000000000..6d3a97ec11 --- /dev/null +++ b/frontend/app/web-gold/src/views/content-style/components/SavePromptModal.vue @@ -0,0 +1,138 @@ + + + + diff --git a/frontend/app/web-gold/src/views/content-style/composables/useBenchmarkAnalysis.js b/frontend/app/web-gold/src/views/content-style/composables/useBenchmarkAnalysis.js new file mode 100644 index 0000000000..bb416a7317 --- /dev/null +++ b/frontend/app/web-gold/src/views/content-style/composables/useBenchmarkAnalysis.js @@ -0,0 +1,157 @@ +import { ref } from 'vue' +import { message } from 'ant-design-vue' +import { ChatMessageApi } from '@/api/chat' +import useVoiceText from '@gold/hooks/web/useVoiceText' +import { streamChat } from '@/utils/streamChat' +import { buildPromptFromTranscription } from '../utils/benchmarkUtils' + +export function useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSession) { + const loading = ref(false) + const batchAnalyzeLoading = ref(false) + const globalLoading = ref(false) + const globalLoadingText = ref('') + const { getVoiceText } = useVoiceText() + + /** + * 分析单个视频,获取提示词 + */ + async function analyzeVideo(row) { + try { + if (row._analyzing) return + + row._analyzing = true + + // 1) 获取音频转写 + message.info('正在获取音频转写...') + const transcriptions = await getVoiceText([row]) + row.transcriptions = transcriptions.find(item => item.audio_url === row.audio_url)?.value + + // 2) 检查是否有语音文案 + if (!row.transcriptions || !row.transcriptions.trim()) { + message.warning('未提取到语音内容,请检查音频文件或稍后重试') + row._analyzing = false + return false + } + + // 3) 创建对话 + message.info('正在创建对话...') + const createPayload = { roleId: 20, role_id: 20 } + console.debug('createChatConversationMy payload:', createPayload) + const conversationResp = await ChatMessageApi.createChatConversationMy(createPayload) + + let conversationId = null + if (conversationResp?.data) { + conversationId = typeof conversationResp.data === 'object' ? conversationResp.data.id : conversationResp.data + } + + if (!conversationId) { + throw new Error('创建对话失败:未获取到 conversationId') + } + + // 4) 基于转写构建提示,流式生成并实时写入 UI + message.info('正在生成提示词...') + const content = buildPromptFromTranscription(row.transcriptions) + const index = data.value.findIndex(item => item.id === row.id) + const aiContent = await streamChat({ + conversationId, + content, + onUpdate: (fullText) => { + if (index !== -1) data.value[index].prompt = fullText + }, + enableTypewriter: true, + typewriterSpeed: 10, + typewriterBatchSize: 2 + }) + + // 5) 兜底处理 + const finalPrompt = aiContent || row.transcriptions || '' + if (index !== -1) data.value[index].prompt = finalPrompt + + // 6) 分析完成后自动展开该行 + const rowId = String(row.id) // 确保类型一致 + if (!expandedRowKeys.value.includes(rowId)) { + expandedRowKeys.value.push(rowId) + } + + // 7) 保存数据到 session + await saveTableDataToSession() + + message.success('分析完成') + return true + } catch (error) { + console.error('分析视频失败:', error) + message.error('分析失败,请稍后重试') + return false + } finally { + row._analyzing = false + } + } + + /** + * 批量分析选中的视频 + */ + async function batchAnalyze(selectedRowKeys, onBatchComplete) { + if (selectedRowKeys.value.length === 0) { + message.warning('请先选择要分析的视频') + return + } + + batchAnalyzeLoading.value = true + globalLoading.value = true + globalLoadingText.value = `正在批量分析 ${selectedRowKeys.value.length} 个视频...` + + try { + // 1. 获取所有选中视频的语音转写 + globalLoadingText.value = '正在获取中...' + const selectedRows = data.value.filter(item => selectedRowKeys.value.includes(item.id)) + const transcriptions = await getVoiceText(selectedRows) + + // 2. 收集所有转写内容 + const allTexts = [] + for (const id of selectedRowKeys.value) { + const row = data.value.find(item => item.id === id) + if (row && row.audio_url) { + const transcription = transcriptions.find(item => item.audio_url === row.audio_url) + if (transcription && transcription.value && transcription.value.trim()) { + allTexts.push({ id: row.id, url: row.audio_url, text: transcription.value }) + row.transcriptions = transcription.value + } + } + } + + // 3. 检查是否有可用的语音内容 + if (allTexts.length === 0) { + message.warning('未提取到任何语音内容,请检查音频文件或稍后重试') + batchAnalyzeLoading.value = false + globalLoading.value = false + globalLoadingText.value = '' + return + } + + await saveTableDataToSession() + const mergedText = allTexts.map(item => item.text).join('\n\n---\n\n') + + // 4. 通知父组件打开弹窗并开始生成 + if (onBatchComplete) { + await onBatchComplete(mergedText, allTexts.length) + } + } catch (error) { + console.error('批量分析失败:', error) + message.error('批量分析失败,请稍后重试') + } finally { + batchAnalyzeLoading.value = false + globalLoading.value = false + globalLoadingText.value = '' + } + } + + return { + loading, + batchAnalyzeLoading, + globalLoading, + globalLoadingText, + analyzeVideo, + batchAnalyze, + } +} + diff --git a/frontend/app/web-gold/src/views/content-style/composables/useBenchmarkData.js b/frontend/app/web-gold/src/views/content-style/composables/useBenchmarkData.js new file mode 100644 index 0000000000..7951f713d6 --- /dev/null +++ b/frontend/app/web-gold/src/views/content-style/composables/useBenchmarkData.js @@ -0,0 +1,80 @@ +import { ref, reactive } from 'vue' +import storage from '@/utils/storage' +import { mapFromDouyin, mapFromXhs } from '../utils/benchmarkUtils' + +const TABLE_DATA_STORAGE_KEY = 'benchmark_table_data' + +export function useBenchmarkData() { + const data = ref([]) + const selectedRowKeys = ref([]) + const expandedRowKeys = ref([]) + + /** + * 保存表格数据到 session + */ + async function saveTableDataToSession() { + try { + // 过滤掉不需要持久化的临时字段(如 _analyzing) + const persistData = (data.value || []).map((item) => { + const rest = { ...(item || {}) } + delete rest._analyzing + return rest + }) + await storage.setJSON(TABLE_DATA_STORAGE_KEY, persistData) + } catch (error) { + console.warn('保存表格数据到session失败:', error) + } + } + + /** + * 从 session 加载表格数据 + */ + async function loadTableDataFromSession() { + try { + const savedData = await storage.getJSON(TABLE_DATA_STORAGE_KEY) + if (savedData && Array.isArray(savedData) && savedData.length > 0) { + // 强制恢复临时字段的初始状态 + data.value = savedData.map((item) => ({ ...item, _analyzing: false })) + console.log('从session加载了表格数据:', savedData.length, '条') + } + } catch (error) { + console.warn('从session加载表格数据失败:', error) + } + } + + /** + * 处理 API 响应数据 + */ + function processApiResponse(resp, platform) { + if (platform === '抖音') { + const awemeList = resp?.data?.aweme_list || [] + console.log('抖音返回的原始数据:', awemeList[0]) + data.value = mapFromDouyin(awemeList) + console.log('映射后的第一条数据:', data.value[0]) + } else { + const notes = resp?.data?.notes || resp?.data?.data || [] + data.value = mapFromXhs(notes) + } + } + + /** + * 清空数据 + */ + async function clearData() { + data.value = [] + selectedRowKeys.value = [] + expandedRowKeys.value = [] + await storage.remove(TABLE_DATA_STORAGE_KEY) + } + + return { + data, + selectedRowKeys, + expandedRowKeys, + saveTableDataToSession, + loadTableDataFromSession, + processApiResponse, + clearData, + } +} + diff --git a/frontend/app/web-gold/src/views/content-style/utils/benchmarkUtils.js b/frontend/app/web-gold/src/views/content-style/utils/benchmarkUtils.js new file mode 100644 index 0000000000..76ad5146c4 --- /dev/null +++ b/frontend/app/web-gold/src/views/content-style/utils/benchmarkUtils.js @@ -0,0 +1,60 @@ +import dayjs from 'dayjs' + +/** + * 映射抖音数据结构 + */ +export function mapFromDouyin(awemeList) { + return awemeList.map((item, idx) => ({ + id: item?.statistics?.aweme_id || item?.aweme_id || idx + 1, + cover: item?.video?.origin_cover?.url_list?.[0] || item?.video?.cover?.url_list?.[0] + || item?.video?.dynamic_cover?.url_list?.[0] || item?.video?.animated_cover?.url_list?.[0] || '', + is_top: item?.is_top ? 1 : 0, + create_time: item?.create_time, + audio_url: item?.video?.play_addr?.url_list?.reverse()[0] || '', + desc: item?.desc || item?.caption || '', + duration_s: Math.round((item?.video?.duration ?? 0) / 1000), + digg_count: item?.statistics?.digg_count ?? 0, + comment_count: item?.statistics?.comment_count ?? 0, + share_count: item?.statistics?.share_count ?? 0, + collect_count: item?.statistics?.collect_count ?? 0, + play_count: item?.statistics?.play_count ?? 0, + share_url: item?.share_info?.share_url || '', + })) +} + +/** + * 映射小红书数据结构 + */ +export function mapFromXhs(notes) { + return notes.map((note, idx) => ({ + id: note?.note_id || note?.id || idx + 1, + cover: note?.cover?.url || note?.image_list?.[0]?.url || '', + is_top: 0, + create_time: note?.time || note?.create_time, + desc: note?.desc || note?.title || '', + duration_s: 0, + digg_count: note?.liked_count ?? note?.likes ?? 0, + comment_count: note?.comment_count ?? 0, + share_count: note?.share_count ?? 0, + play_count: note?.view_count ?? 0, + share_url: note?.link || '', + })) +} + +/** + * 格式化时间戳 + */ +export function formatTime(ts) { + if (!ts) return '' + const ms = ts > 1e12 ? ts : ts * 1000 + return dayjs(ms).format('YYYY-MM-DD HH:mm:ss') +} + +/** + * 根据转写内容构建 AI 提示 + */ +export function buildPromptFromTranscription(text) { + if (text && text.trim()) return `${text}` + return '没有可用的语音转写内容,请给出一份适合短视频脚本创作的通用高质量提示词模板(包含框架、角色、语气、风格、内容要点等)。' +} + diff --git a/frontend/app/web-gold/src/views/system/StyleSettings.vue b/frontend/app/web-gold/src/views/system/StyleSettings.vue new file mode 100644 index 0000000000..c20f77c527 --- /dev/null +++ b/frontend/app/web-gold/src/views/system/StyleSettings.vue @@ -0,0 +1,489 @@ + + + + + + diff --git a/frontend/config/api.js b/frontend/config/api.js index 180d57dc88..9132f0c928 100644 --- a/frontend/config/api.js +++ b/frontend/config/api.js @@ -30,10 +30,12 @@ export const API_BASE = { APP: `${BASE_URL}`, // 具体模块路径 ADMIN_AI: `${BASE_URL}/admin-api/ai`, + APP_AI: `${BASE_URL}/app-api/ai`, APP_MEMBER: `${BASE_URL}/app-api/member`, // 特殊路径 TIKHUB_APP: `${BASE_URL}/api/tikHup`, + AI_APP: `${BASE_URL}/api/ai`, } /** diff --git a/frontend/hooks/web/README.md b/frontend/hooks/web/README.md index 2c5cf38187..26cf2cec4a 100644 --- a/frontend/hooks/web/README.md +++ b/frontend/hooks/web/README.md @@ -107,34 +107,13 @@ export const useUserStore = defineStore('user', () => { 语音文本转换 Hook,将音频文件转换为文本转录。 -#### 初始化(在应用启动时) - -```javascript -// 在应用的 API 服务文件中(如 common.js) -import { createApiService } from '@gold/config/api/services' -import { setApiService } from '@gold/hooks/web/useVoiceText' -import http from '@/api/http' -import { getAuthHeader } from '@/utils/token-manager' -import { API_BASE } from '@gold/config/api' - -// 创建 API 服务实例 -const apiService = createApiService({ - http, - getAuthHeader, - baseUrl: API_BASE.TIKHUB_APP, -}) - -// 设置全局 API 服务(供 useVoiceText hook 使用) -setApiService(apiService) -``` - #### 使用方式 ```javascript import useVoiceText from '@gold/hooks/web/useVoiceText' import type { AudioItem } from '@gold/config/types' -// 在组件中使用 +// 在组件中使用(无需任何初始化) const { getVoiceText } = useVoiceText() const audioList: AudioItem[] = [ @@ -145,6 +124,11 @@ const transcriptions = await getVoiceText(audioList) // transcriptions: [{ key: 'url', value: 'transcribed text', audio_url: '...' }] ``` +#### 说明 + +`useVoiceText` Hook 直接使用 mono 级别的 `TikHubService`,无需任何初始化或配置。 +所有 API 服务都在 `@gold/api/services` 中统一管理,开箱即用。 + #### 类型定义 ```typescript @@ -159,7 +143,8 @@ import type { - `vue`: Vue 3 Composition API - `axios`: HTTP 请求库(用于 useUserInfo) - `@gold/config/api`: 公共 API 配置 -- `@gold/config/api/services`: 公共 API 服务创建器 +- `@gold/api/services`: Mono 级别的 API 服务 +- `@gold/api/axios/client`: Mono 级别的 Axios 客户端 - `@gold/config/types`: 公共类型定义 ## 🔧 配置要求 diff --git a/frontend/hooks/web/useUserInfo.js b/frontend/hooks/web/useUserInfo.js index bf3f59cda0..c818a51fc5 100644 --- a/frontend/hooks/web/useUserInfo.js +++ b/frontend/hooks/web/useUserInfo.js @@ -106,8 +106,6 @@ export function useUserInfo(options = {}) { // code 为 0 或 200 表示成功 if (response.data.code === 0 || response.data.code === 200) { data = response.data.data || response.data - } else { - throw new Error(response.data.msg || response.data.message || '获取用户信息失败') } } else { // 没有 code 字段,直接使用 data @@ -118,8 +116,6 @@ export function useUserInfo(options = {}) { if (data) { userInfo.value = data return data - } else { - throw new Error('获取用户信息失败:响应数据为空') } } catch (err) { error.value = err diff --git a/frontend/hooks/web/useVoiceText.ts b/frontend/hooks/web/useVoiceText.ts index 6a9af840fa..22e1491074 100644 --- a/frontend/hooks/web/useVoiceText.ts +++ b/frontend/hooks/web/useVoiceText.ts @@ -6,28 +6,12 @@ import type { TranscriptionData } from '@gold/config/types' -/** - * API 服务接口(需要从应用层注入) - */ -interface ApiService { - videoToCharacters: (data: { fileLinkList: string[] }) => Promise<{ data: string }> -} - -// 全局 API 服务实例(由应用层设置) -let apiServiceInstance: ApiService | null = null - -/** - * 设置 API 服务实例 - * @param service - API 服务对象 - */ -export function setApiService(service: ApiService) { - apiServiceInstance = service -} +// 直接导入 TikHub 服务,无需全局注入 +import { TikHubService } from '@gold/api/services' /** * 将音频列表转换为文本转录 * @param list - 音频项列表 - * @param apiService - API 服务实例(可选,如果已通过 setApiService 设置则不需要) * @returns 转录结果数组 * @throws 当转录过程出错时抛出错误 * @@ -37,18 +21,10 @@ export function setApiService(service: ApiService) { * console.log(transcriptions) // [{ key: 'url', value: 'transcribed text' }] */ export async function getVoiceText( - list: AudioItem[], - apiService?: ApiService + list: AudioItem[] ): Promise { - // 使用传入的 apiService 或全局实例 - const service = apiService || apiServiceInstance - - if (!service) { - throw new Error('getVoiceText: 需要提供 API 服务实例。请使用 setApiService() 设置或传入 apiService 参数') - } - - // 调用API将视频转换为文本 - const ret = await service.videoToCharacters({ + // 直接使用 TikHub 服务 + const ret = await TikHubService.videoToCharacters({ fileLinkList: list.map(item => item.audio_url), }) @@ -85,28 +61,20 @@ export async function getVoiceText( * Hook 返回值接口 */ interface UseVoiceTextReturn { - getVoiceText: (list: AudioItem[], apiService?: ApiService) => Promise + getVoiceText: (list: AudioItem[]) => Promise } /** * 语音文本转换 Hook - * @param apiService - API 服务实例(可选,如果已通过 setApiService 设置则不需要) * @returns 包含 getVoiceText 方法的对象 * * @example - * // 方式一:使用全局设置的 API 服务 - * setApiService(myApiService) * const { getVoiceText } = useVoiceText() * const result = await getVoiceText(audioList) - * - * @example - * // 方式二:传入 API 服务实例 - * const { getVoiceText } = useVoiceText() - * const result = await getVoiceText(audioList, myApiService) */ -export default function useVoiceText(apiService?: ApiService): UseVoiceTextReturn { +export default function useVoiceText(): UseVoiceTextReturn { return { - getVoiceText: (list: AudioItem[]) => getVoiceText(list, apiService) + getVoiceText } } diff --git a/frontend/utils/TOKEN_STORAGE.md b/frontend/utils/TOKEN_STORAGE.md deleted file mode 100644 index 31083c6260..0000000000 --- a/frontend/utils/TOKEN_STORAGE.md +++ /dev/null @@ -1,160 +0,0 @@ -# Token 存储位置说明 - -## 📍 Token 存储位置总览 - -Token 在项目中有 **3 个存储位置**,按读取优先级排序: - -### 1. **Dev Token(开发手动输入的 token)** -- **存储位置**:`sessionStorage` -- **键名**:`DEV_MANUAL_TOKEN` -- **设置方式**:`setDevToken(token)` -- **特点**: - - 优先级最高(读取时优先使用) - - 关闭浏览器标签页后自动清除 - - 用于开发测试 - -**浏览器查看方式:** -```javascript -// 在浏览器控制台执行 -sessionStorage.getItem('DEV_MANUAL_TOKEN') -``` - -### 2. **正式登录的 Token(主要存储)** -- **存储位置**:`localStorage`(通过 WebStorageCache 封装) -- **键名**: - - `ACCESS_TOKEN` 或 `access_token`(访问令牌) - - `REFRESH_TOKEN` 或 `refresh_token`(刷新令牌) -- **设置方式**:`setToken({ accessToken, refreshToken })` -- **特点**: - - 持久化存储(关闭浏览器后仍然存在) - - 使用 `WebStorageCache` 库管理 - - 支持大小写不同的键名变体(兼容性) - -**浏览器查看方式:** -```javascript -// 在浏览器控制台执行 -localStorage.getItem('ACCESS_TOKEN') -localStorage.getItem('REFRESH_TOKEN') -// 或者 -localStorage.getItem('access_token') -localStorage.getItem('refresh_token') -``` - -**实际存储结构:** -``` -localStorage: - ├── ACCESS_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - └── REFRESH_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -``` - -### 3. **环境变量 Token(兜底)** -- **存储位置**:代码中(不存储在浏览器) -- **变量名**:`VITE_DEV_TOKEN` -- **设置方式**:`.env` 文件或环境变量 -- **特点**: - - 只在代码中读取,不写入浏览器存储 - - 优先级最低(前两者都没有时才使用) - - 用于开发环境默认配置 - -## 🔄 Token 读取优先级 - -当调用 `getToken()` 时,按以下顺序查找: - -``` -1. sessionStorage['DEV_MANUAL_TOKEN'] ← 最高优先级 - ↓ (如果没有) -2. localStorage['ACCESS_TOKEN'] 或 localStorage['access_token'] - ↓ (如果没有) -3. import.meta.env.VITE_DEV_TOKEN ← 最低优先级 -``` - -## 📝 代码示例 - -### 设置 Token - -```javascript -import { setToken, setDevToken } from '@gold/utils/token-manager' - -// 设置正式登录的 token(存储到 localStorage) -setToken({ - accessToken: 'xxx', - refreshToken: 'yyy' -}) - -// 设置开发 token(存储到 sessionStorage) -setDevToken('dev-token-123') -``` - -### 读取 Token - -```javascript -import { getToken } from '@gold/utils/token-manager' - -// 自动按优先级读取 -const token = getToken() -``` - -### 清除 Token - -```javascript -import { clearAllTokens } from '@gold/utils/token-manager' - -// 清除所有位置的 token -clearAllTokens() -// 会清除: -// 1. sessionStorage['DEV_MANUAL_TOKEN'] -// 2. localStorage['ACCESS_TOKEN'] 和 'access_token' -// 3. localStorage['REFRESH_TOKEN'] 和 'refresh_token' -``` - -## 🔍 在浏览器中查看 - -### Chrome DevTools - -1. **打开 DevTools** (F12) -2. **Application 标签页** -3. **Storage 部分**: - - **Local Storage** → 查看 `ACCESS_TOKEN`、`REFRESH_TOKEN` - - **Session Storage** → 查看 `DEV_MANUAL_TOKEN` - -### 控制台命令 - -```javascript -// 查看所有 token -console.log('Dev Token:', sessionStorage.getItem('DEV_MANUAL_TOKEN')) -console.log('Access Token:', localStorage.getItem('ACCESS_TOKEN')) -console.log('Refresh Token:', localStorage.getItem('REFRESH_TOKEN')) - -// 查看所有 localStorage -console.table(localStorage) - -// 查看所有 sessionStorage -console.table(sessionStorage) -``` - -## ⚠️ 注意事项 - -1. **WebStorageCache 封装**: - - `useCache()` 默认使用 `localStorage` - - 通过 `WebStorageCache` 库管理,支持过期时间等功能 - - 实际存储位置仍然是 `localStorage` - -2. **键名大小写**: - - 代码中统一使用 `ACCESS_TOKEN` 和 `REFRESH_TOKEN`(大写) - - 但为了兼容,也支持 `access_token` 和 `refresh_token`(小写) - - 读取时会尝试所有变体 - -3. **清除逻辑**: - - `clearAllTokens()` 会清除所有位置的 token - - 包括 sessionStorage、localStorage 的所有变体键名 - - 确保完全清除,避免残留 - -## 📊 存储位置总结表 - -| Token 类型 | 存储位置 | 键名 | 持久化 | 优先级 | -|-----------|---------|------|--------|--------| -| Dev Token | sessionStorage | `DEV_MANUAL_TOKEN` | ❌ 关闭标签页清除 | 1 (最高) | -| Access Token | localStorage | `ACCESS_TOKEN` / `access_token` | ✅ 持久化 | 2 | -| Refresh Token | localStorage | `REFRESH_TOKEN` / `refresh_token` | ✅ 持久化 | 2 | -| Env Token | 代码中 | `VITE_DEV_TOKEN` | N/A | 3 (最低) | - diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/controller/app/AppAiChatConversationController.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/controller/app/AppAiChatConversationController.java new file mode 100644 index 0000000000..9681fdc875 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/controller/app/AppAiChatConversationController.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.ai.chat.controller.app; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai.chat.vo.app.AppAiChatConversationCreateMyReqVO; +import cn.iocoder.yudao.module.ai.chat.vo.app.AppAiChatConversationRespVO; +import cn.iocoder.yudao.module.ai.chat.vo.app.AppAiChatConversationUpdateMyReqVO; +import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.conversation.AiChatConversationCreateMyReqVO; +import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.conversation.AiChatConversationUpdateMyReqVO; +import cn.iocoder.yudao.module.ai.dal.dataobject.chat.AiChatConversationDO; +import cn.iocoder.yudao.module.ai.service.chat.AiChatConversationService; +import com.fhs.core.trans.anno.TransMethodResult; +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.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "用户 App - AI 聊天对话") +@RestController +@RequestMapping("/ai/chat/conversation") +@Validated +public class AppAiChatConversationController { + + @Resource + private AiChatConversationService chatConversationService; + + @PostMapping("/create-my") + @Operation(summary = "创建【我的】聊天对话") + public CommonResult createChatConversationMy(@RequestBody @Valid AppAiChatConversationCreateMyReqVO createReqVO) { + // 将 App VO 转换为 Admin VO + AiChatConversationCreateMyReqVO adminReqVO = BeanUtils.toBean(createReqVO, AiChatConversationCreateMyReqVO.class); + return success(chatConversationService.createChatConversationMy(adminReqVO, getLoginUserId())); + } + + @PutMapping("/update-my") + @Operation(summary = "更新【我的】聊天对话") + public CommonResult updateChatConversationMy(@RequestBody @Valid AppAiChatConversationUpdateMyReqVO updateReqVO) { + // 将 App VO 转换为 Admin VO + AiChatConversationUpdateMyReqVO adminReqVO = BeanUtils.toBean(updateReqVO, AiChatConversationUpdateMyReqVO.class); + chatConversationService.updateChatConversationMy(adminReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/my-list") + @Operation(summary = "获得【我的】聊天对话列表") + @TransMethodResult + public CommonResult> getChatConversationMyList() { + List list = chatConversationService.getChatConversationListByUserId(getLoginUserId()); + return success(BeanUtils.toBean(list, AppAiChatConversationRespVO.class)); + } + + @GetMapping("/get-my") + @Operation(summary = "获得【我的】聊天对话") + @Parameter(name = "id", required = true, description = "对话编号", example = "1024") + @TransMethodResult + public CommonResult getChatConversationMy(@RequestParam("id") Long id) { + AiChatConversationDO conversation = chatConversationService.getChatConversation(id); + if (conversation != null && ObjUtil.notEqual(conversation.getUserId(), getLoginUserId())) { + conversation = null; + } + return success(BeanUtils.toBean(conversation, AppAiChatConversationRespVO.class)); + } + + @DeleteMapping("/delete-my") + @Operation(summary = "删除聊天对话") + @Parameter(name = "id", required = true, description = "对话编号", example = "1024") + public CommonResult deleteChatConversationMy(@RequestParam("id") Long id) { + chatConversationService.deleteChatConversationMy(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete-by-unpinned") + @Operation(summary = "删除未置顶的聊天对话") + public CommonResult deleteChatConversationMyByUnpinned() { + chatConversationService.deleteChatConversationMyByUnpinned(getLoginUserId()); + return success(true); + } + +} + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/controller/app/AppAiChatMessageController.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/controller/app/AppAiChatMessageController.java new file mode 100644 index 0000000000..102c069e88 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/controller/app/AppAiChatMessageController.java @@ -0,0 +1,194 @@ +package cn.iocoder.yudao.module.ai.chat.controller.app; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai.chat.vo.app.AppAiChatMessageRespVO; +import cn.iocoder.yudao.module.ai.chat.vo.app.AppAiChatMessageSendReqVO; +import cn.iocoder.yudao.module.ai.chat.vo.app.AppAiChatMessageSendRespVO; +import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message.AiChatMessageSendReqVO; +import cn.iocoder.yudao.module.ai.dal.dataobject.chat.AiChatConversationDO; +import cn.iocoder.yudao.module.ai.dal.dataobject.chat.AiChatMessageDO; +import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO; +import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO; +import cn.iocoder.yudao.module.ai.service.chat.AiChatConversationService; +import cn.iocoder.yudao.module.ai.service.chat.AiChatMessageService; +import cn.iocoder.yudao.module.ai.service.knowledge.AiKnowledgeDocumentService; +import cn.iocoder.yudao.module.ai.service.knowledge.AiKnowledgeSegmentService; +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.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "用户 App - 聊天消息") +@RestController +@RequestMapping("/ai/chat/message") +@Validated +@Slf4j +public class AppAiChatMessageController { + + @Resource + private AiChatMessageService chatMessageService; + @Resource + private AiChatConversationService chatConversationService; + @Resource + private AiKnowledgeSegmentService knowledgeSegmentService; + @Resource + private AiKnowledgeDocumentService knowledgeDocumentService; + + @Operation(summary = "发送消息(段式)", description = "一次性返回,响应较慢") + @PostMapping("/send") + public CommonResult sendMessage(@Valid @RequestBody AppAiChatMessageSendReqVO sendReqVO) { + // 将 App VO 转换为 Admin VO + AiChatMessageSendReqVO adminReqVO = BeanUtils.toBean(sendReqVO, AiChatMessageSendReqVO.class); + // 调用 Service,然后转换响应 + var adminResp = chatMessageService.sendMessage(adminReqVO, getLoginUserId()); + // 手动转换 segments,因为内部类类型不同 + AppAiChatMessageSendRespVO appResp = convertSendRespVO(adminResp); + return success(appResp); + } + + /** + * 将 Admin 的 SendRespVO 转换为 App 的 SendRespVO + * 主要处理 segments 字段的类型转换 + */ + private AppAiChatMessageSendRespVO convertSendRespVO(cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message.AiChatMessageSendRespVO adminResp) { + if (adminResp == null) { + return null; + } + AppAiChatMessageSendRespVO appResp = new AppAiChatMessageSendRespVO(); + + // 转换 send + if (adminResp.getSend() != null) { + AppAiChatMessageSendRespVO.Message appSend = BeanUtils.toBean(adminResp.getSend(), AppAiChatMessageSendRespVO.Message.class); + if (adminResp.getSend().getSegments() != null) { + appSend.setSegments(convertKnowledgeSegments(adminResp.getSend().getSegments())); + } + appResp.setSend(appSend); + } + + // 转换 receive + if (adminResp.getReceive() != null) { + AppAiChatMessageSendRespVO.Message appReceive = BeanUtils.toBean(adminResp.getReceive(), AppAiChatMessageSendRespVO.Message.class); + if (adminResp.getReceive().getSegments() != null) { + appReceive.setSegments(convertKnowledgeSegments(adminResp.getReceive().getSegments())); + } + appResp.setReceive(appReceive); + } + + return appResp; + } + + /** + * 转换 KnowledgeSegment 列表 + */ + private List convertKnowledgeSegments( + List adminSegments) { + if (adminSegments == null) { + return null; + } + return convertList(adminSegments, segment -> { + if (segment == null) { + return null; + } + return new AppAiChatMessageSendRespVO.KnowledgeSegment() + .setId(segment.getId()) + .setContent(segment.getContent()) + .setDocumentId(segment.getDocumentId()) + .setDocumentName(segment.getDocumentName()); + }); + } + + @Operation(summary = "发送消息(流式)", description = "流式返回,响应较快") + @PostMapping(value = "/send-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux> sendChatMessageStream(@Valid @RequestBody AppAiChatMessageSendReqVO sendReqVO) { + // 将 App VO 转换为 Admin VO + AiChatMessageSendReqVO adminReqVO = BeanUtils.toBean(sendReqVO, AiChatMessageSendReqVO.class); + // 调用 Service,然后转换响应流 + return chatMessageService.sendChatMessageStream(adminReqVO, getLoginUserId()) + .map(result -> { + if (result.getData() != null) { + // 手动转换 segments,因为内部类类型不同 + AppAiChatMessageSendRespVO appResp = convertSendRespVO(result.getData()); + return success(appResp); + } + return success((AppAiChatMessageSendRespVO) null); + }); + } + + @Operation(summary = "获得指定对话的消息列表") + @GetMapping("/list-by-conversation-id") + @Parameter(name = "conversationId", required = true, description = "对话编号", example = "1024") + public CommonResult> getChatMessageListByConversationId( + @RequestParam("conversationId") Long conversationId) { + AiChatConversationDO conversation = chatConversationService.getChatConversation(conversationId); + if (conversation == null || ObjUtil.notEqual(conversation.getUserId(), getLoginUserId())) { + return success(Collections.emptyList()); + } + // 1. 获取消息列表 + List messageList = chatMessageService.getChatMessageListByConversationId(conversationId); + if (CollUtil.isEmpty(messageList)) { + return success(Collections.emptyList()); + } + + // 2. 拼接数据,主要是知识库段落信息 + Map segmentMap = knowledgeSegmentService.getKnowledgeSegmentMap(convertListByFlatMap(messageList, + message -> CollUtil.isEmpty(message.getSegmentIds()) ? null : message.getSegmentIds().stream())); + Map documentMap = knowledgeDocumentService.getKnowledgeDocumentMap( + convertList(segmentMap.values(), AiKnowledgeSegmentDO::getDocumentId)); + List messageVOList = BeanUtils.toBean(messageList, AppAiChatMessageRespVO.class); + for (int i = 0; i < messageList.size(); i++) { + AiChatMessageDO message = messageList.get(i); + if (CollUtil.isEmpty(message.getSegmentIds())) { + continue; + } + // 设置知识库段落信息 + messageVOList.get(i).setSegments(convertList(message.getSegmentIds(), segmentId -> { + AiKnowledgeSegmentDO segment = segmentMap.get(segmentId); + if (segment == null) { + return null; + } + AiKnowledgeDocumentDO document = documentMap.get(segment.getDocumentId()); + if (document == null) { + return null; + } + return new AppAiChatMessageRespVO.KnowledgeSegment().setId(segment.getId()).setContent(segment.getContent()) + .setDocumentId(segment.getDocumentId()).setDocumentName(document.getName()); + })); + } + return success(messageVOList); + } + + @Operation(summary = "删除消息") + @DeleteMapping("/delete") + @Parameter(name = "id", required = true, description = "消息编号", example = "1024") + public CommonResult deleteChatMessage(@RequestParam("id") Long id) { + chatMessageService.deleteChatMessage(id, getLoginUserId()); + return success(true); + } + + @Operation(summary = "删除指定对话的消息") + @DeleteMapping("/delete-by-conversation-id") + @Parameter(name = "conversationId", required = true, description = "对话编号", example = "1024") + public CommonResult deleteChatMessageByConversationId(@RequestParam("conversationId") Long conversationId) { + chatMessageService.deleteChatMessageByConversationId(conversationId, getLoginUserId()); + return success(true); + } + +} + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationCreateMyReqVO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationCreateMyReqVO.java new file mode 100644 index 0000000000..2f7eaf2abb --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationCreateMyReqVO.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.ai.chat.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "用户 App - AI 聊天对话创建【我的】 Request VO") +@Data +public class AppAiChatConversationCreateMyReqVO { + + @Schema(description = "聊天角色编号", example = "666") + private Long roleId; + + @Schema(description = "知识库编号", example = "1204") + private Long knowledgeId; + +} + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationRespVO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationRespVO.java new file mode 100644 index 0000000000..98aabf46e6 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationRespVO.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.ai.chat.vo.app; + +import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO; +import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatRoleDO; +import com.fhs.core.trans.anno.Trans; +import com.fhs.core.trans.constant.TransType; +import com.fhs.core.trans.vo.VO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "用户 App - AI 聊天对话 Response VO") +@Data +public class AppAiChatConversationRespVO implements VO { + + @Schema(description = "对话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long userId; + + @Schema(description = "对话标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "我是一个标题") + private String title; + + @Schema(description = "是否置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean pinned; + + @Schema(description = "角色编号", example = "1") + @Trans(type = TransType.SIMPLE, target = AiChatRoleDO.class, fields = {"name", "avatar"}, refs = {"roleName", "roleAvatar"}) + private Long roleId; + + @Schema(description = "模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @Trans(type = TransType.SIMPLE, target = AiModelDO.class, fields = "name", ref = "modelName") + private Long modelId; + + @Schema(description = "模型标志", requiredMode = Schema.RequiredMode.REQUIRED, example = "ERNIE-Bot-turbo-0922") + private String model; + + @Schema(description = "模型名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三") + private String modelName; + + @Schema(description = "角色设定", example = "一个快乐的程序员") + private String systemMessage; + + @Schema(description = "温度参数", requiredMode = Schema.RequiredMode.REQUIRED, example = "0.8") + private Double temperature; + + @Schema(description = "单条回复的最大 Token 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "4096") + private Integer maxTokens; + + @Schema(description = "上下文的最大 Message 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer maxContexts; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + // ========== 关联 role 信息 ========== + + @Schema(description = "角色头像", example = "https://www.iocoder.cn/1.png") + private String roleAvatar; + + @Schema(description = "角色名字", example = "小黄") + private String roleName; + +} + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationUpdateMyReqVO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationUpdateMyReqVO.java new file mode 100644 index 0000000000..57e8e16d42 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatConversationUpdateMyReqVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.ai.chat.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "用户 App - AI 聊天对话更新【我的】 Request VO") +@Data +public class AppAiChatConversationUpdateMyReqVO { + + @Schema(description = "对话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "对话编号不能为空") + private Long id; + + @Schema(description = "对话标题", example = "我是一个标题") + private String title; + + @Schema(description = "是否置顶", example = "true") + private Boolean pinned; + + @Schema(description = "模型编号", example = "1") + private Long modelId; + + @Schema(description = "知识库编号", example = "1") + private Long knowledgeId; + + @Schema(description = "角色设定", example = "一个快乐的程序员") + private String systemMessage; + + @Schema(description = "温度参数", example = "0.8") + private Double temperature; + + @Schema(description = "单条回复的最大 Token 数量", example = "4096") + private Integer maxTokens; + + @Schema(description = "上下文的最大 Message 数量", example = "10") + private Integer maxContexts; + +} + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageRespVO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageRespVO.java new file mode 100644 index 0000000000..0144612747 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageRespVO.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.ai.chat.vo.app; + +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "用户 App - AI 聊天消息 Response VO") +@Data +public class AppAiChatMessageRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "对话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long conversationId; + + @Schema(description = "回复消息编号", example = "1024") + private Long replyId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "role") + private String type; // 参见 MessageType 枚举类 + + @Schema(description = "用户编号", example = "4096") + private Long userId; + + @Schema(description = "角色编号", example = "888") + private Long roleId; + + @Schema(description = "模型标志", requiredMode = Schema.RequiredMode.REQUIRED, example = "gpt-3.5-turbo") + private String model; + + @Schema(description = "模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "123") + private Long modelId; + + @Schema(description = "聊天内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "你好,你好啊") + private String content; + + @Schema(description = "推理内容", example = "要达到这个目标,你需要...") + private String reasoningContent; + + @Schema(description = "是否携带上下文", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean useContext; + + @Schema(description = "知识库段落编号数组", example = "[1,2,3]") + private List segmentIds; + + @Schema(description = "知识库段落数组") + private List segments; + + @Schema(description = "联网搜索的网页内容数组") + private List webSearchPages; + + @Schema(description = "附件 URL 数组", example = "https://www.iocoder.cn/1.png") + private List attachmentUrls; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-05-12 12:51") + private LocalDateTime createTime; + + @Schema(description = "知识库段落", example = "Java 开发手册") + @Data + public static class KnowledgeSegment { + + @Schema(description = "段落编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "切片内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 开发手册") + private String content; + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long documentId; + + @Schema(description = "文档名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品使用手册") + private String documentName; + + } + +} + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageSendReqVO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageSendReqVO.java new file mode 100644 index 0000000000..2a2a1b4016 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageSendReqVO.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.ai.chat.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "用户 App - AI 聊天消息发送 Request VO") +@Data +public class AppAiChatMessageSendReqVO { + + @Schema(description = "聊天对话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "聊天对话编号不能为空") + private Long conversationId; + + @Schema(description = "聊天内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "帮我写个 Java 算法") + @NotEmpty(message = "聊天内容不能为空") + private String content; + + @Schema(description = "是否携带上下文", example = "true") + private Boolean useContext; + + @Schema(description = "是否联网搜索", example = "true") + private Boolean useSearch; + + @Schema(description = "附件 URL 数组", example = "https://www.iocoder.cn/1.png") + private List attachmentUrls; + +} + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageSendRespVO.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageSendRespVO.java new file mode 100644 index 0000000000..dfa95ec03d --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/chat/vo/app/AppAiChatMessageSendRespVO.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.ai.chat.vo.app; + +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "用户 App - AI 聊天消息发送 Response VO") +@Data +public class AppAiChatMessageSendRespVO { + + @Schema(description = "发送消息", requiredMode = Schema.RequiredMode.REQUIRED) + private Message send; + + @Schema(description = "接收消息", requiredMode = Schema.RequiredMode.REQUIRED) + private Message receive; + + @Schema(description = "消息") + @Data + public static class Message { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "role") + private String type; // 参见 MessageType 枚举类 + + @Schema(description = "聊天内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "你好,你好啊") + private String content; + + @Schema(description = "推理内容", example = "要达到这个目标,你需要...") + private String reasoningContent; + + @Schema(description = "知识库段落编号数组", example = "[1,2,3]") + private List segmentIds; + + @Schema(description = "知识库段落数组") + private List segments; + + @Schema(description = "联网搜索的网页内容数组") + private List webSearchPages; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + } + + @Schema(description = "知识库段落", example = "Java 开发手册") + @Data + public static class KnowledgeSegment { + + @Schema(description = "段落编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "切片内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 开发手册") + private String content; + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long documentId; + + @Schema(description = "文档名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品使用手册") + private String documentName; + + } + +} + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/UserPromptController.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/app/AppUserPromptController.java similarity index 51% rename from yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/UserPromptController.java rename to yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/app/AppUserPromptController.java index 10f96cfdb1..7ff24cba39 100644 --- a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/UserPromptController.java +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/controller/app/AppUserPromptController.java @@ -1,54 +1,50 @@ -package cn.iocoder.yudao.module.ai.userprompt.controller; +package cn.iocoder.yudao.module.ai.userprompt.controller.app; -import org.springframework.web.bind.annotation.*; -import jakarta.annotation.Resource; -import org.springframework.validation.annotation.Validated; -import org.springframework.security.access.prepost.PreAuthorize; -import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Operation; - -import jakarta.validation.constraints.*; -import jakarta.validation.*; -import jakarta.servlet.http.*; -import java.util.*; -import java.io.IOException; - -import cn.iocoder.yudao.framework.common.pojo.PageParam; -import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; - -import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; - -import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; -import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.*; - -import cn.iocoder.yudao.module.ai.userprompt.vo.*; import cn.iocoder.yudao.module.ai.dal.dataobject.userprompt.UserPromptDO; import cn.iocoder.yudao.module.ai.userprompt.service.UserPromptService; +import cn.iocoder.yudao.module.ai.userprompt.vo.UserPromptPageReqVO; +import cn.iocoder.yudao.module.ai.userprompt.vo.UserPromptRespVO; +import cn.iocoder.yudao.module.ai.userprompt.vo.UserPromptSaveReqVO; +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.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; -@Tag(name = "AI - 用户提示词") +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.USER_PROMPT_NOT_EXISTS; + +@Tag(name = "用户 App - 用户提示词") @RestController @RequestMapping("/ai/user-prompt") @Validated -public class UserPromptController { +public class AppUserPromptController { @Resource private UserPromptService userPromptService; @PostMapping("/create") @Operation(summary = "创建用户提示词") - @PreAuthorize("@ss.hasPermission('ai:user-prompt:create')") public CommonResult createUserPrompt(@Valid @RequestBody UserPromptSaveReqVO createReqVO) { + // 设置当前登录用户ID + createReqVO.setUserId(getLoginUserId()); return success(userPromptService.createUserPrompt(createReqVO)); } @PutMapping("/update") @Operation(summary = "更新用户提示词") - @PreAuthorize("@ss.hasPermission('ai:user-prompt:update')") - public CommonResult updateUserPrompt(@Valid @RequestBody UserPromptSaveReqVO updateReqVO) { + public CommonResult updateUserPrompt(@RequestBody UserPromptSaveReqVO updateReqVO) { + // 设置当前登录用户ID,确保只能更新自己的提示词 + updateReqVO.setUserId(getLoginUserId()); + // 更新时,如果前端没有传递 sort、useCount、isPublic 等字段,后端会自动从数据库获取 + // 注意:移除了 @Valid 注解,因为这些字段在更新时可以为空,后端会自动填充 userPromptService.updateUserPrompt(updateReqVO); return success(true); } @@ -56,49 +52,36 @@ public class UserPromptController { @DeleteMapping("/delete") @Operation(summary = "删除用户提示词") @Parameter(name = "id", description = "编号", required = true) - @PreAuthorize("@ss.hasPermission('ai:user-prompt:delete')") public CommonResult deleteUserPrompt(@RequestParam("id") Long id) { + // 验证是否为当前用户的提示词 + UserPromptDO userPrompt = userPromptService.getUserPrompt(id); + if (userPrompt == null || !userPrompt.getUserId().equals(getLoginUserId())) { + // 提示词不存在或不属于当前用户,返回错误 + throw exception(USER_PROMPT_NOT_EXISTS); + } userPromptService.deleteUserPrompt(id); return success(true); } - @DeleteMapping("/delete-list") - @Parameter(name = "ids", description = "编号", required = true) - @Operation(summary = "批量删除用户提示词") - @PreAuthorize("@ss.hasPermission('ai:user-prompt:delete')") - public CommonResult deleteUserPromptList(@RequestParam("ids") List ids) { - userPromptService.deleteUserPromptListByIds(ids); - return success(true); - } - @GetMapping("/get") @Operation(summary = "获得用户提示词") @Parameter(name = "id", description = "编号", required = true, example = "1024") - @PreAuthorize("@ss.hasPermission('ai:user-prompt:query')") public CommonResult getUserPrompt(@RequestParam("id") Long id) { UserPromptDO userPrompt = userPromptService.getUserPrompt(id); + // 验证是否为当前用户的提示词 + if (userPrompt != null && !userPrompt.getUserId().equals(getLoginUserId())) { + userPrompt = null; + } return success(BeanUtils.toBean(userPrompt, UserPromptRespVO.class)); } @GetMapping("/page") @Operation(summary = "获得用户提示词分页") - @PreAuthorize("@ss.hasPermission('ai:user-prompt:query')") public CommonResult> getUserPromptPage(@Valid UserPromptPageReqVO pageReqVO) { + // 设置当前登录用户ID,只查询当前用户的提示词 + pageReqVO.setUserId(getLoginUserId()); PageResult pageResult = userPromptService.getUserPromptPage(pageReqVO); return success(BeanUtils.toBean(pageResult, UserPromptRespVO.class)); } - - @GetMapping("/export-excel") - @Operation(summary = "导出用户提示词 Excel") - @PreAuthorize("@ss.hasPermission('ai:user-prompt:export')") - @ApiAccessLog(operateType = EXPORT) - public void exportUserPromptExcel(@Valid UserPromptPageReqVO pageReqVO, - HttpServletResponse response) throws IOException { - pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); - List list = userPromptService.getUserPromptPage(pageReqVO).getList(); - // 导出 Excel - ExcelUtils.write(response, "用户提示词.xls", "数据", UserPromptRespVO.class, - BeanUtils.toBean(list, UserPromptRespVO.class)); - } - } + diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptServiceImpl.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptServiceImpl.java index 1ed720ddec..b8fe304a23 100644 --- a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptServiceImpl.java +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/userprompt/service/UserPromptServiceImpl.java @@ -44,10 +44,35 @@ public class UserPromptServiceImpl implements UserPromptService { @Override public void updateUserPrompt(UserPromptSaveReqVO updateReqVO) { - // 校验存在 - validateUserPromptExists(updateReqVO.getId()); - // 更新 - UserPromptDO updateObj = BeanUtils.toBean(updateReqVO, UserPromptDO.class); + // 1. 手动验证前端表单字段(与前端表单对应) + if (updateReqVO.getName() == null || updateReqVO.getName().trim().isEmpty()) { + throw new IllegalArgumentException("提示词名称不能为空"); + } + if (updateReqVO.getContent() == null || updateReqVO.getContent().trim().isEmpty()) { + throw new IllegalArgumentException("提示词内容不能为空"); + } + if (updateReqVO.getStatus() == null) { + throw new IllegalArgumentException("状态不能为空"); + } + + // 2. 校验存在并获取现有记录 + UserPromptDO existing = validateUserPromptExists(updateReqVO.getId()); + + // 3. 手动设置要更新的字段(只更新前端表单中的字段) + UserPromptDO updateObj = new UserPromptDO(); + updateObj.setId(updateReqVO.getId()); + updateObj.setName(updateReqVO.getName().trim()); + updateObj.setContent(updateReqVO.getContent().trim()); + updateObj.setCategory(updateReqVO.getCategory() != null ? updateReqVO.getCategory().trim() : null); + updateObj.setStatus(updateReqVO.getStatus()); + + // 4. 自动填充前端表单中没有的字段(从数据库获取) + updateObj.setSort(existing.getSort()); + updateObj.setUseCount(existing.getUseCount()); + updateObj.setIsPublic(existing.getIsPublic()); + updateObj.setUserId(existing.getUserId()); // 保持用户ID不变 + + // 5. 执行更新 userPromptMapper.updateById(updateObj); } @@ -66,10 +91,12 @@ public class UserPromptServiceImpl implements UserPromptService { } - private void validateUserPromptExists(Long id) { - if (userPromptMapper.selectById(id) == null) { + private UserPromptDO validateUserPromptExists(Long id) { + UserPromptDO userPrompt = userPromptMapper.selectById(id); + if (userPrompt == null) { throw exception(USER_PROMPT_NOT_EXISTS); } + return userPrompt; } @Override