mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-08-12 10:40:58 +00:00
feat(release): migrate GeekAI v4.3.0 to open source
- Sync backend and frontend from GeekAI Plus v4.3.0 - Remove commercial License flows and update open-source deployment defaults - Preserve Docker Compose deployment and bump image tags to v4.3.0 BREAKING CHANGE: commercial License configuration and related endpoints are removed
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
# ui-ux-pro-max
|
||||
|
||||
Searchable database of UI styles, color palettes, font pairings, chart types, product recommendations, UX guidelines, and stack-specific best practices.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Check if Python is installed:
|
||||
|
||||
```bash
|
||||
python3 --version || python --version
|
||||
```
|
||||
|
||||
If Python is not installed, install it based on user's OS:
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install python3
|
||||
```
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt update && sudo apt install python3
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
winget install Python.Python.3.12
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Workflow
|
||||
|
||||
When user requests UI/UX work (design, build, create, implement, review, fix, improve), follow this workflow:
|
||||
|
||||
### Step 1: Analyze User Requirements
|
||||
|
||||
Extract key information from user request:
|
||||
- **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
|
||||
- **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
|
||||
- **Industry**: healthcare, fintech, gaming, education, etc.
|
||||
- **Stack**: React, Vue, Next.js, or default to `html-tailwind`
|
||||
|
||||
### Step 2: Search Relevant Domains
|
||||
|
||||
Use `search.py` multiple times to gather comprehensive information. Search until you have enough context.
|
||||
|
||||
```bash
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <domain> [-n <max_results>]
|
||||
```
|
||||
|
||||
**Recommended search order:**
|
||||
|
||||
1. **Product** - Get style recommendations for product type
|
||||
2. **Style** - Get detailed style guide (colors, effects, frameworks)
|
||||
3. **Typography** - Get font pairings with Google Fonts imports
|
||||
4. **Color** - Get color palette (Primary, Secondary, CTA, Background, Text, Border)
|
||||
5. **Landing** - Get page structure (if landing page)
|
||||
6. **Chart** - Get chart recommendations (if dashboard/analytics)
|
||||
7. **UX** - Get best practices and anti-patterns
|
||||
8. **Stack** - Get stack-specific guidelines (default: html-tailwind)
|
||||
|
||||
### Step 3: Stack Guidelines (Default: html-tailwind)
|
||||
|
||||
If user doesn't specify a stack, **default to `html-tailwind`**.
|
||||
|
||||
```bash
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "<keyword>" --stack html-tailwind
|
||||
```
|
||||
|
||||
Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`, `react-native`, `flutter`
|
||||
|
||||
---
|
||||
|
||||
## Search Reference
|
||||
|
||||
### Available Domains
|
||||
|
||||
| Domain | Use For | Example Keywords |
|
||||
|--------|---------|------------------|
|
||||
| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
|
||||
| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
|
||||
| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
|
||||
| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
|
||||
| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
|
||||
| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
|
||||
| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
|
||||
| `prompt` | AI prompts, CSS keywords | (style name) |
|
||||
|
||||
### Available Stacks
|
||||
|
||||
| Stack | Focus |
|
||||
|-------|-------|
|
||||
| `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
|
||||
| `react` | State, hooks, performance, patterns |
|
||||
| `nextjs` | SSR, routing, images, API routes |
|
||||
| `vue` | Composition API, Pinia, Vue Router |
|
||||
| `svelte` | Runes, stores, SvelteKit |
|
||||
| `swiftui` | Views, State, Navigation, Animation |
|
||||
| `react-native` | Components, Navigation, Lists |
|
||||
| `flutter` | Widgets, State, Layout, Theming |
|
||||
|
||||
---
|
||||
|
||||
## Example Workflow
|
||||
|
||||
**User request:** "Làm landing page cho dịch vụ chăm sóc da chuyên nghiệp"
|
||||
|
||||
**AI should:**
|
||||
|
||||
```bash
|
||||
# 1. Search product type
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --domain product
|
||||
|
||||
# 2. Search style (based on industry: beauty, elegant)
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "elegant minimal soft" --domain style
|
||||
|
||||
# 3. Search typography
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "elegant luxury" --domain typography
|
||||
|
||||
# 4. Search color palette
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "beauty spa wellness" --domain color
|
||||
|
||||
# 5. Search landing page structure
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "hero-centric social-proof" --domain landing
|
||||
|
||||
# 6. Search UX guidelines
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "animation" --domain ux
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "accessibility" --domain ux
|
||||
|
||||
# 7. Search stack guidelines (default: html-tailwind)
|
||||
python3 .shared/ui-ux-pro-max/scripts/search.py "layout responsive" --stack html-tailwind
|
||||
```
|
||||
|
||||
**Then:** Synthesize all search results and implement the design.
|
||||
|
||||
---
|
||||
|
||||
## Tips for Better Results
|
||||
|
||||
1. **Be specific with keywords** - "healthcare SaaS dashboard" > "app"
|
||||
2. **Search multiple times** - Different keywords reveal different insights
|
||||
3. **Combine domains** - Style + Typography + Color = Complete design system
|
||||
4. **Always check UX** - Search "animation", "z-index", "accessibility" for common issues
|
||||
5. **Use stack flag** - Get implementation-specific best practices
|
||||
6. **Iterate** - If first search doesn't match, try different keywords
|
||||
7. **Split Into Multiple Files** - For better maintainability:
|
||||
- Separate components into individual files (e.g., `Header.tsx`, `Footer.tsx`)
|
||||
- Extract reusable styles into dedicated files
|
||||
- Keep each file focused and under 200-300 lines
|
||||
|
||||
---
|
||||
|
||||
## Common Rules for Professional UI
|
||||
|
||||
These are frequently overlooked issues that make UI look unprofessional:
|
||||
|
||||
### Icons & Visual Elements
|
||||
|
||||
| Rule | Do | Don't |
|
||||
|------|----|----- |
|
||||
| **No emoji icons** | Use SVG icons (Heroicons, Lucide, Simple Icons) | Use emojis like 🎨 🚀 ⚙️ as UI icons |
|
||||
| **Stable hover states** | Use color/opacity transitions on hover | Use scale transforms that shift layout |
|
||||
| **Correct brand logos** | Research official SVG from Simple Icons | Guess or use incorrect logo paths |
|
||||
| **Consistent icon sizing** | Use fixed viewBox (24x24) with w-6 h-6 | Mix different icon sizes randomly |
|
||||
|
||||
### Interaction & Cursor
|
||||
|
||||
| Rule | Do | Don't |
|
||||
|------|----|----- |
|
||||
| **Cursor pointer** | Add `cursor-pointer` to all clickable/hoverable cards | Leave default cursor on interactive elements |
|
||||
| **Hover feedback** | Provide visual feedback (color, shadow, border) | No indication element is interactive |
|
||||
| **Smooth transitions** | Use `transition-colors duration-200` | Instant state changes or too slow (>500ms) |
|
||||
|
||||
### Light/Dark Mode Contrast
|
||||
|
||||
| Rule | Do | Don't |
|
||||
|------|----|----- |
|
||||
| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent) |
|
||||
| **Text contrast light** | Use `#0F172A` (slate-900) for text | Use `#94A3B8` (slate-400) for body text |
|
||||
| **Muted text light** | Use `#475569` (slate-600) minimum | Use gray-400 or lighter |
|
||||
| **Border visibility** | Use `border-gray-200` in light mode | Use `border-white/10` (invisible) |
|
||||
|
||||
### Layout & Spacing
|
||||
|
||||
| Rule | Do | Don't |
|
||||
|------|----|----- |
|
||||
| **Floating navbar** | Add `top-4 left-4 right-4` spacing | Stick navbar to `top-0 left-0 right-0` |
|
||||
| **Content padding** | Account for fixed navbar height | Let content hide behind fixed elements |
|
||||
| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths |
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering UI code, verify these items:
|
||||
|
||||
### Visual Quality
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] Brand logos are correct (verified from Simple Icons)
|
||||
- [ ] Hover states don't cause layout shift
|
||||
|
||||
### Interaction
|
||||
- [ ] All clickable elements have `cursor-pointer`
|
||||
- [ ] Hover states provide clear visual feedback
|
||||
- [ ] Transitions are smooth (150-300ms)
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
|
||||
### Light/Dark Mode
|
||||
- [ ] Light mode text has sufficient contrast (4.5:1 minimum)
|
||||
- [ ] Glass/transparent elements visible in light mode
|
||||
- [ ] Borders visible in both modes
|
||||
- [ ] Test both modes before delivery
|
||||
|
||||
### Layout
|
||||
- [ ] Floating elements have proper spacing from edges
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] Responsive at 320px, 768px, 1024px, 1440px
|
||||
- [ ] No horizontal scroll on mobile
|
||||
|
||||
### Accessibility
|
||||
- [ ] All images have alt text
|
||||
- [ ] Form inputs have labels
|
||||
- [ ] Color is not the only indicator
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
+3
-1
@@ -15,4 +15,6 @@ logs
|
||||
*.sln
|
||||
*.sw?
|
||||
miniprogram
|
||||
api/test
|
||||
.shared
|
||||
.claude
|
||||
docs/mydocs
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## 项目结构与模块组织
|
||||
- `api/`:Go + Gin 后端,包含 `core/` 业务、`handler/` 控制器、`service/` 接口调用以及 `store/` 数据访问,`config.toml` 存放默认配置,`Makefile` 用于多架构交叉构建。
|
||||
- `web/`:Vue3 + Vite 前端,源码集中在 `src/`,`public/` 为静态资源,`dist/` 存放构建结果并可被 `api/static/` 或 `desktop/` 引用。
|
||||
- `desktop/`:Electron 客户端入口为 `index.js`,配合 `electron-builder` 可打包 AppImage/DMG/NSIS。
|
||||
- `miniprogram/`、`docs/`、`database/` 与 `config/` 分别承载小程序壳、部署文档、SQL 脚本及全局 YAML 配置;`build/` 包含 Dockerfile、安装脚本。
|
||||
|
||||
## 构建、测试与开发命令
|
||||
- `docker-compose up -d`:根目录拉起全部容器,需提前准备好 MySQL、Redis 与模型密钥。
|
||||
- `cd api && go run main.go`:本地热调试;`make amd64` / `make arm64` 生成无 CGO 二进制至 `api/bin/` 便于镜像打包。
|
||||
- `cd web && pnpm install && pnpm dev --host`:Vite 开发模式;`pnpm build` 产出静态文件;`pnpm lint` 运行 ESLint 自动修复。
|
||||
- `cd desktop && npm install && npm run start`:调试 Electron;`npm run package` 通过 electron-builder 生成多平台安装包。
|
||||
|
||||
## 编码风格与命名规范
|
||||
- Go 代码必须经过 `gofmt`/`goimports`,保持 tab 缩进与驼峰命名;HTTP 路由遵循 `/api/v1/resources` 模式,与 handler 函数命名 (`ResourceHandler`) 对应。
|
||||
- Vue 组件文件使用 PascalCase(如 `ChatPanel.vue`),Pinia store 与工具采用 kebab-case 文件名(如 `chat-session.ts`);统一通过 ESLint、Tailwind 与 `postcss.config.js` 约束样式。
|
||||
|
||||
## 测试指南
|
||||
- `cd api && go test ./... -race` 是最低要求,新增 service/handler 需补 `_test.go` 并用 mock 隔离第三方 API;涉及时序逻辑可新增 `Test*Integration` 验证。
|
||||
- 前端暂未启用单测框架,至少运行 `pnpm lint` 并在 PR 中附关键页面截图或录屏证明交互可用;桌面端如修改构建脚本,需在 macOS/Linux/Windows 中至少验证一个安装包。
|
||||
|
||||
## 提交与 Pull Request 规范
|
||||
- 参考历史记录(如“支持腾讯云短信服务”),提交信息使用中文动词开头、聚焦单一变更,并可加子系统前缀:`web: 优化聊天动画`。
|
||||
- PR 描述需包含变更背景、实现概述、验证方式(命令、截图或日志)与关联 issue/任务号;涉及配置或部署脚本,还要说明回滚流程并 @ 相关 reviewer。
|
||||
|
||||
## 安全与配置提示
|
||||
- 禁止提交真实密钥,请复制 `config.sample.toml` 或 `config/config.yaml` 生成私有文件,并用 `git update-index --skip-worktree` 忽略。
|
||||
- 对象存储、短信、支付等凭证统一放入 Vault 或 CI Secret,代码中仅引用占位常量;`docs/` 中同步记录新增敏感字段与启用步骤。
|
||||
+36
-1
@@ -1,5 +1,41 @@
|
||||
# 更新日志
|
||||
|
||||
## v4.3.0
|
||||
|
||||
- 功能新增:**PPT 生成功能**,复刻 NotebookLLM 的演示文稿生成功能,支持编辑和导出 🔥🔥🔥
|
||||
- 功能新增:AI 绘图与视频创作提示词支持 @ 引用图片 🔥🔥🔥
|
||||
- 功能新增:接入豆包视频生成,优化 Sora 图生视频体验 🔥🔥🔥
|
||||
- 功能重构:移除 Stable Diffusion(SD)生图模块及相关前后端代码;数据库表 `geekai_sd_jobs` 保留不删除;管理后台算力配置移除 SD、可灵、Luma 等相关算力项
|
||||
- 功能重构:图片生成模块从 dalle 重命名为 image,支持查看任务详情 🔥🔥🔥
|
||||
- 功能优化:任务详情弹窗优化并补充图片任务创建时间
|
||||
- 功能优化:图片生成页尺寸支持自定义输入与预设选择;优化比例与尺寸参数展示
|
||||
- 功能优化:优化上传组件,支持拖拽上传;更新 iconfont;图片接口改造以兼容 kapon 聚合服务
|
||||
- 功能优化:MJ 支持局部重绘与算力配置优化;任务列表返回创建时间
|
||||
- 功能重构:重构 Gem(智能体)模块,调整智能体标识与工作区存储
|
||||
- 功能优化:优化 ChatPlus 欢迎页与会话输入体验,与 ImageMj 交互及 UI 一致性
|
||||
- 功能优化:支持批量导入用户信息,优化管理后台 UI 样式
|
||||
- 功能优化:重构管理后台 UI,采用现代科技风格
|
||||
- 功能优化:优化左侧菜单 UI,增加选中菜单对比度
|
||||
- Bug修复:完善数据迁移中各表自增主键及用户表主键迁移;迁移时清理历史 Dalle 与 SD 相关数据
|
||||
|
||||
## v4.2.9
|
||||
|
||||
- 功能优化:支持缩略图功能配置,支持本地存储,七牛云,腾讯云 OSS 和阿里云 OSS 四种存储介质。
|
||||
- 功能优化:重构视频生成模块,支持 Sora2, Veo3.1, Luma, 可灵,通义万相,MiniMax 等视频生成模型。
|
||||
- 功能新增:腾讯云短信服务 🔥🔥🔥
|
||||
- 功能新增:支持腾讯云 OSS 存储,支持腾讯云 COS 文件上传和下载 🔥🔥🔥
|
||||
- 功能新增:支持微信支付原生的 JSAPI 支付,支持微信公众号授权登录。🔥🔥🔥
|
||||
- 功能优化:对话页面输入框支持粘贴剪切板内容上传截图 🔥🔥🔥
|
||||
- 功能优化:给 Sora2 生成视频下载增加重试机制,防止因为网络不稳定导致下载失败 🎉🎉🎉
|
||||
- Bug 修复:删除即梦 4.0 生图不支持的分片率参数,支持 1K, 2K, 4K 分辨率
|
||||
- 功能优化:即梦 AI 新增 3.0 电商营销产品背景替换功能 🔥🔥🔥
|
||||
- Bug 修复:调整 PC 端会员页面的列表样式,重构页面布局,移除多余的 css 样式,采用 tailwindcss 样式
|
||||
- Bug 修复:修复管理后台重置管理密码不生效的问题
|
||||
- Bug 修复:修复 Chat 页面输入框输入内容会自动滚动到最底部的问题
|
||||
- Bug 修复:修复即梦视频生成任务 duration(视频时长) 参数不生效的问题 🎉🎉🎉
|
||||
- Bug 修复:修复管理后台系统配置邮箱白名单更改不生效问题
|
||||
- Bug 修复:修复聊天页面角色列表显示 system prompt 提示词的问题
|
||||
|
||||
## v4.2.8
|
||||
|
||||
- Bug 修复:修复管理后台邮件配置报“参数错误”问题
|
||||
@@ -12,7 +48,6 @@
|
||||
- Bug 修复:移动端登录页面输入密码的时候会覆盖在确认按钮上层
|
||||
- 功能优化:**移动端的 DALL-E 绘图页面支持上传参考图** 🔥🔥🔥
|
||||
- 功能优化:优化 WebFooter 组件,如果配置了备案号,则显示备案号,否则不显示
|
||||
- 功能优化:启动时自动同步 model 与数据表字段,缺列自动新建、多余列自动删除
|
||||
|
||||
## v4.2.7
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Key entities: User, ChatItem, ChatMessage, ChatRole, ChatModel, Order, Product,
|
||||
### API Structure
|
||||
- User APIs: `/api/user/*` (auth, profile, settings)
|
||||
- Chat APIs: `/api/chat/*` (conversations, messages)
|
||||
- AI Service APIs: `/api/mj/*`, `/api/sd/*`, `/api/dall/*`, `/api/suno/*`, `/api/video/*`
|
||||
- AI Service APIs: `/api/mj/*`, `/api/dall/*`, `/api/suno/*`, `/api/video/*`
|
||||
- Admin APIs: `/api/admin/*` (management functions)
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# 项目固定指令(Cursor 必须遵守)
|
||||
|
||||
1. 代码必须使用清晰命名,禁止单字母变量
|
||||
2. 代码必须加注释
|
||||
3. 统一使用 4 空格缩进
|
||||
4. 前端样式代码永远优先适应 Tailwind CSS 的样式, 别自定义样式。除非 Tailwind CSS 不支持或者做不到。
|
||||
5. 不要生成多余代码,保持简洁
|
||||
6. 每次生成代码前遵守以上规则
|
||||
@@ -1,15 +1,15 @@
|
||||
# 🚀 GeekAI-PLUS:一站式 AI 创意生产力平台
|
||||
# 🚀 GeekAI:一站式 AI 创意生产力平台
|
||||
|
||||
**重新定义 AI 创作体验,让每个人都能成为内容创作大师**
|
||||
**让创意触手可及,让创作变得简单**
|
||||
|
||||
基于 GeekAI 项目开发的高级版,增加了很多高级功能,比如思维导图,Dalle 绘画等。**高级版源码不会一次性开放,只提供镜像给大家免费使用**,源码会逐步逐步按照版同步迁移到[社区版(GeekAI)](https://github.com/yangjian102621/geekai)。所以如果大家想要二次开发,请移步去社区版。
|
||||
一个功能完整、开箱即用的 AI 多模态内容创作平台,集成了对话、绘画、音乐、视频、思维导图等全链路 AI 创作能力。无论是个人创作者还是企业团队,都能快速搭建属于自己的 AI 创作工作台。
|
||||
|
||||
## ✨ 核心特色
|
||||
|
||||
### 🎨 **全能 AI 创作矩阵**
|
||||
|
||||
- **智能对话**:集成 ChatGPT、Claude 等多款顶级 AI 模型,支持角色扮演和专业对话
|
||||
- **图像生成**:整合 MidJourney、DALL-E、Stable Diffusion 三大主流 AI 绘画引擎
|
||||
- **图像生成**:整合 MidJourney、DALL-E、Nano-Banana,即梦,可灵等主流 AI 绘画引擎
|
||||
- **音频创作**:Suno AI 音乐生成,从旋律到歌词一键创作专属音乐
|
||||
- **视频制作**:Luma 和 KeLing,即梦,Veo3 视频 AI,文本到视频,创意无限
|
||||
- **思维导图**:AI 辅助思维整理,复杂想法可视化呈现
|
||||
@@ -34,8 +34,8 @@
|
||||
|
||||
- **响应式设计**:完美适配桌面、平板、手机等全终端设备
|
||||
- **暗黑模式**:支持明暗主题切换,护眼舒适
|
||||
- **实时交互**:WebSocket 实时通信,创作过程流畅无卡顿
|
||||
- **文件管理**:支持多种云存储,作品安全可靠
|
||||
- **实时交互**:流式响应,创作过程流畅无卡顿
|
||||
- **文件管理**:支持七牛云,阿里云 OSS,腾讯云 OSS,Minio 等多种云存储,作品安全可靠
|
||||
|
||||
## 🎪 **应用场景**
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
- **教育培训**:课件制作、知识图谱、互动内容
|
||||
- **个人娱乐**:AI 聊天、创意绘画、音乐创作
|
||||
|
||||
## 🔥 **为什么选择 GeekAI-PLUS?**
|
||||
## 🔥 **为什么选择 GeekAI?**
|
||||
|
||||
1. **技术领先**:集成当前最先进的 AI 技术,始终保持创新前沿
|
||||
2. **开箱即用**:完整的商业化解决方案,无需从零开发
|
||||
@@ -54,11 +54,11 @@
|
||||
|
||||
## 演示站点
|
||||
|
||||
[Geek-AI 创作系统](https://www.geekai.me)
|
||||
[Geek-AI 创作系统](https://chat.geekai.me)
|
||||
|
||||
## 文档地址
|
||||
|
||||
[Geek-AI 文档](https://www.geekai.me/docs/)
|
||||
[Geek-AI 文档](https://docs.geekai.me)
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
+1
-100
@@ -1,6 +1,6 @@
|
||||
Listen = "0.0.0.0:5678"
|
||||
ProxyURL = "" # 如 http://127.0.0.1:7777
|
||||
MysqlDns = "root:12345678@tcp(localhost:3306)/geekai?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local"
|
||||
MysqlDns = "root:password@tcp(127.0.0.1:3306)/geekai?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local"
|
||||
StaticDir = "./static" # 静态资源的目录
|
||||
StaticUrl = "/static" # 静态资源访问 URL
|
||||
TikaHost = "http://tika:9998"
|
||||
@@ -14,102 +14,3 @@ TikaHost = "http://tika:9998"
|
||||
Port = 6379
|
||||
Password = ""
|
||||
DB = 0
|
||||
|
||||
[ApiConfig] # 微博热搜,今日头条等函数服务 API 配置,此为第三方插件服务,如需使用请联系作者开通
|
||||
ApiURL = "https://sapi.geekai.me"
|
||||
AppId = ""
|
||||
Token = ""
|
||||
|
||||
|
||||
[SMS] # Sms 配置,用于发送短信
|
||||
Active = "Ali" # 当前启用的短信服务,默认使用阿里云
|
||||
[SMS.Bao]
|
||||
Username = ""
|
||||
Password = ""
|
||||
Domain = "api.smsbao.com"
|
||||
Sign = "【极客学长】"
|
||||
CodeTemplate = "您的验证码是{code}。5分钟有效,若非本人操作,请忽略本短信。"
|
||||
[SMS.Ali]
|
||||
AccessKey = ""
|
||||
AccessSecret = ""
|
||||
Product = "Dysmsapi"
|
||||
Domain = "dysmsapi.aliyuncs.com"
|
||||
Sign = ""
|
||||
CodeTempId = ""
|
||||
|
||||
[OSS] # OSS 配置,用于存储 MJ 绘画图片
|
||||
Active = "local" # 默认使用本地文件存储引擎
|
||||
[OSS.Local]
|
||||
BasePath = "./static/upload" # 本地文件上传根路径
|
||||
BaseURL = "http://localhost:5678/static/upload" # 本地上传文件前缀 URL,线上需要把 localhost 替换成自己的实际域名或者IP
|
||||
[OSS.Minio]
|
||||
Endpoint = "" # 如 172.22.11.200:9000
|
||||
AccessKey = "" # 自己去 Minio 控制台去创建一个 Access Key
|
||||
AccessSecret = ""
|
||||
Bucket = "chatgpt-plus" # 替换为你自己创建的 Bucket,注意要给 Bucket 设置公开的读权限,否则会出现图片无法显示。
|
||||
UseSSL = false
|
||||
Domain = "" # 地址必须是能够通过公网访问的,否则会出现图片无法显示。
|
||||
[OSS.QiNiu] # 七牛云 OSS 配置
|
||||
Zone = "z2" # 区域,z0:华东,z1: 华北,na0:北美,as0:新加坡
|
||||
AccessKey = ""
|
||||
AccessSecret = ""
|
||||
Bucket = ""
|
||||
Domain = "" # OSS Bucket 所绑定的域名,如 https://img.r9it.com
|
||||
[OSS.AliYun]
|
||||
Endpoint = "oss-cn-hangzhou.aliyuncs.com"
|
||||
AccessKey = ""
|
||||
AccessSecret = ""
|
||||
Bucket = "chatgpt-plus"
|
||||
SubDir = ""
|
||||
Domain = ""
|
||||
|
||||
[XXLConfig] # xxl-job 配置,需要你部署 XXL-JOB 定时任务工具,用来定期清理未支付订单和清理过期 VIP,如果你没有启用支付服务,则该服务也无需启动
|
||||
Enabled = false # 是否启用 XXL JOB 服务
|
||||
ServerAddr = "http://172.22.11.47:8080/xxl-job-admin" # xxl-job-admin 管理地址
|
||||
ExecutorIp = "172.22.11.47" # 执行器 IP 地址
|
||||
ExecutorPort = "9999" # 执行器服务端口
|
||||
AccessToken = "xxl-job-api-token" # 执行器 API 通信 token
|
||||
RegistryKey = "chatgpt-plus" # 任务注册 key
|
||||
|
||||
[SmtpConfig] # 注意,阿里云服务器禁用了25号端口,请使用 465 端口,并开启 TLS 连接
|
||||
UseTls = false
|
||||
Host = "smtp.163.com"
|
||||
Port = 25
|
||||
AppName = "极客学长"
|
||||
From = "test@163.com" # 发件邮箱人地址
|
||||
Password = "" #邮箱 stmp 服务授权码
|
||||
|
||||
# 支付宝商户支付
|
||||
[AlipayConfig]
|
||||
Enabled = false # 启用支付宝支付通道
|
||||
SandBox = false # 是否启用沙盒模式
|
||||
UserId = "2088721020750581" # 商户ID
|
||||
AppId = "9021000131658023" # App Id
|
||||
PrivateKey = "certs/alipay/privateKey.txt" # 应用私钥
|
||||
PublicKey = "certs/alipay/appPublicCert.crt" # 应用公钥证书
|
||||
AlipayPublicKey = "certs/alipay/alipayPublicCert.crt" # 支付宝公钥证书
|
||||
RootCert = "certs/alipay/alipayRootCert.crt" # 支付宝根证书
|
||||
|
||||
# 虎皮椒支付
|
||||
[HuPiPayConfig]
|
||||
Enabled = false
|
||||
AppId = ""
|
||||
AppSecret = ""
|
||||
ApiURL = "https://api.xunhupay.com"
|
||||
|
||||
# 微信商户支付
|
||||
[WechatPayConfig]
|
||||
Enabled = false
|
||||
AppId = "" # 商户应用ID
|
||||
MchId = "" # 商户号
|
||||
SerialNo = "" # API 证书序列号
|
||||
PrivateKey = "certs/alipay/privateKey.txt" # API 证书私钥文件路径,跟支付宝一样,把私钥文件拷贝到对应的路径,证书路径要映射到容器内
|
||||
ApiV3Key = "" # APIV3 私钥,这个是你自己在微信支付平台设置的
|
||||
|
||||
# 易支付
|
||||
[GeekPayConfig]
|
||||
Enabled = true
|
||||
AppId = "" # 商户ID
|
||||
PrivateKey = "" # 商户私钥
|
||||
ApiURL = "https://pay.geekai.cn"
|
||||
Methods = ["alipay", "wxpay", "qqpay", "jdpay", "douyin", "paypal"] # 支持的支付方式
|
||||
|
||||
+12
-2
@@ -10,7 +10,7 @@ package core
|
||||
import (
|
||||
"bytes"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"os"
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
func NewDefaultConfig() *types.AppConfig {
|
||||
return &types.AppConfig{
|
||||
@@ -157,6 +157,15 @@ func LoadSystemConfig(db *gorm.DB) *types.SystemConfig {
|
||||
logger.Error("load jimeng config error: ", err)
|
||||
}
|
||||
|
||||
// 加载微信公众号配置
|
||||
var wxGzhConfig types.WxGzhConfig
|
||||
sysConfig.Id = 0
|
||||
db.Where("name", types.ConfigKeyWxGzh).First(&sysConfig)
|
||||
err = utils.JsonDecode(sysConfig.Value, &wxGzhConfig)
|
||||
if err != nil {
|
||||
logger.Error("load wx gzh config error: ", err)
|
||||
}
|
||||
|
||||
return &types.SystemConfig{
|
||||
Base: baseConfig,
|
||||
SMS: smsConfig,
|
||||
@@ -167,5 +176,6 @@ func LoadSystemConfig(db *gorm.DB) *types.SystemConfig {
|
||||
WxLogin: wxLoginConfig,
|
||||
Moderation: moderationConfig,
|
||||
Jimeng: jimengConfig,
|
||||
WxGzh: wxGzhConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
"time"
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/golang-jwt/jwt"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
// 前端用户授权验证
|
||||
func UserAuthMiddleware(secretKey string, redis *redis.Client) gin.HandlerFunc {
|
||||
|
||||
+24
-18
@@ -57,8 +57,11 @@ type BaseConfig struct {
|
||||
DailyPower int `json:"daily_power,omitempty"` // 每日签到赠送算力
|
||||
InvitePower int `json:"invite_power,omitempty"` // 邀请新用户赠送算力值
|
||||
MjPower int `json:"mj_power,omitempty"` // MJ 绘画消耗算力
|
||||
MjActionPower int `json:"mj_action_power,omitempty"` // MJ 操作(放大,变换)消耗算力
|
||||
SdPower int `json:"sd_power,omitempty"` // SD 绘画消耗算力
|
||||
MjActionPower int `json:"mj_action_power,omitempty"` // MJ 操作(放大,变换)消耗算力,未配置分项时回退用
|
||||
MjUpscalePower int `json:"mj_upscale_power,omitempty"` // MJ 放大/变换消耗算力
|
||||
MjBlendPower int `json:"mj_blend_power,omitempty"` // MJ 融图消耗算力
|
||||
MjSwapFacePower int `json:"mj_swap_face_power,omitempty"` // MJ 换脸消耗算力
|
||||
MjModalPower int `json:"mj_modal_power,omitempty"` // MJ 局部重绘消耗算力
|
||||
SunoPower int `json:"suno_power,omitempty"` // Suno 生成歌曲消耗算力
|
||||
LumaPower int `json:"luma_power,omitempty"` // Luma 生成视频消耗算力
|
||||
KeLingPowers map[string]int `json:"keling_powers,omitempty"` // 可灵生成视频消耗算力
|
||||
@@ -69,8 +72,7 @@ type BaseConfig struct {
|
||||
EnableContext bool `json:"enable_context,omitempty"`
|
||||
ContextDeep int `json:"context_deep,omitempty"`
|
||||
|
||||
SdNegPrompt string `json:"sd_neg_prompt"` // SD 默认反向提示词
|
||||
MjMode string `json:"mj_mode"` // midjourney 默认的API模式,relax, fast, turbo
|
||||
MjMode string `json:"mj_mode"` // midjourney 默认的API模式,relax, fast, turbo
|
||||
|
||||
IndexNavs []int `json:"index_navs"` // 首页显示的导航菜单
|
||||
IndexPage string `json:"index_page"` // 首页显示的页面
|
||||
@@ -95,22 +97,26 @@ type SystemConfig struct {
|
||||
WxLogin WxLoginConfig
|
||||
Jimeng JimengConfig
|
||||
Moderation ModerationConfig
|
||||
WxGzh WxGzhConfig
|
||||
}
|
||||
|
||||
// 配置键名常量
|
||||
const (
|
||||
ConfigKeySystem = "system"
|
||||
ConfigKeyNotice = "notice"
|
||||
ConfigKeyAgreement = "agreement"
|
||||
ConfigKeyPrivacy = "privacy"
|
||||
ConfigKeyMarkMap = "mark_map"
|
||||
ConfigKeyCaptcha = "captcha"
|
||||
ConfigKeyWxLogin = "wx_login"
|
||||
ConfigKeySms = "sms"
|
||||
ConfigKeySmtp = "smtp"
|
||||
ConfigKeyOss = "oss"
|
||||
ConfigKeyPayment = "payment"
|
||||
ConfigKeyModeration = "moderation"
|
||||
ConfigKeyAI3D = "ai3d"
|
||||
ConfigKeyJimeng = "jimeng"
|
||||
ConfigKeySystem = "system" // 系统配置
|
||||
ConfigKeyNotice = "notice" // 公告配置
|
||||
ConfigKeyAgreement = "agreement" // 用户协议配置
|
||||
ConfigKeyPrivacy = "privacy" // 隐私政策配置
|
||||
ConfigKeyMarkMap = "mark_map" // 水印配置
|
||||
ConfigKeyCaptcha = "captcha" // 验证码配置
|
||||
ConfigKeyWxLogin = "wx_login" // 微信扫码登录配置
|
||||
ConfigKeyWxGzh = "wx_gzh" // 微信公众号配置
|
||||
ConfigKeySms = "sms" // 短信配置
|
||||
ConfigKeySmtp = "smtp" // SMTP 配置
|
||||
ConfigKeyOss = "oss" // OSS 配置
|
||||
ConfigKeyPayment = "payment" // 支付配置
|
||||
ConfigKeyModeration = "moderation" // 文本审查配置
|
||||
ConfigKeyAI3D = "ai3d" // AI3D 配置
|
||||
ConfigKeyJimeng = "jimeng" // 即梦AI配置
|
||||
ConfigKeyVideo = "video" // 视频生成配置
|
||||
ConfigKeyPPT = "ppt" // PPT 生成配置
|
||||
)
|
||||
|
||||
@@ -25,9 +25,18 @@ type CaptchaConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// WxLoginConfig 微信登录配置
|
||||
// WxLoginConfig 微信扫码登录配置
|
||||
type WxLoginConfig struct {
|
||||
ApiKey string `json:"api_key,omitempty"`
|
||||
NotifyURL string `json:"notify_url,omitempty"` // 登录成功回调 URL
|
||||
Enabled bool `json:"enabled,omitempty"` // 是否启用微信登录
|
||||
}
|
||||
|
||||
// 微信公众号配置
|
||||
type WxGzhConfig struct {
|
||||
AppId string `json:"app_id,omitempty"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
Token string `json:"token"`
|
||||
EncodingAESKey string `json:"encoding_aes_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -65,8 +65,7 @@ var ModerationCategories = map[string]string{
|
||||
const (
|
||||
ModerationSourceChat = "chat"
|
||||
ModerationSourceMJ = "mj"
|
||||
ModerationSourceDalle = "dalle"
|
||||
ModerationSourceSD = "sd"
|
||||
ModerationSourceImage = "image"
|
||||
ModerationSourceSuno = "suno"
|
||||
ModerationSourceVideo = "video"
|
||||
ModerationSourceJiMeng = "jimeng"
|
||||
|
||||
+37
-23
@@ -8,39 +8,53 @@ package types
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
type OSSConfig struct {
|
||||
Active string `json:"active,omitempty"`
|
||||
Local LocalStorageConfig `json:"local,omitempty"`
|
||||
Minio MiniOssConfig `json:"minio,omitempty"`
|
||||
QiNiu QiNiuOssConfig `json:"qiniu,omitempty"`
|
||||
AliYun AliYunOssConfig `json:"aliyun,omitempty"`
|
||||
Active string `json:"active,omitempty"`
|
||||
Local LocalStorageConfig `json:"local,omitempty"`
|
||||
Minio MiniOssConfig `json:"minio,omitempty"`
|
||||
QiNiu QiNiuOssConfig `json:"qiniu,omitempty"`
|
||||
AliYun AliYunOssConfig `json:"aliyun,omitempty"`
|
||||
Tencent TencentOssConfig `json:"tencent,omitempty"`
|
||||
}
|
||||
|
||||
type MiniOssConfig struct {
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
AccessKey string `json:"access_key,omitempty"`
|
||||
AccessSecret string `json:"access_secret,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
UseSSL bool `json:"use_ssl,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
AccessKey string `json:"access_key,omitempty"`
|
||||
AccessSecret string `json:"access_secret,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
UseSSL bool `json:"use_ssl,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ThumbTemplate string `json:"thumb_template,omitempty"` // 缩略图模板,使用{width}和{height}作为变量占位符
|
||||
}
|
||||
|
||||
type QiNiuOssConfig struct {
|
||||
Zone string `json:"zone,omitempty"`
|
||||
AccessKey string `json:"access_key,omitempty"`
|
||||
AccessSecret string `json:"access_secret,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
AccessKey string `json:"access_key,omitempty"`
|
||||
AccessSecret string `json:"access_secret,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ThumbTemplate string `json:"thumb_template,omitempty"` // 缩略图模板,使用{width}和{height}作为变量占位符,默认:?imageView2/4/w/{width}/h/{height}/q/75
|
||||
}
|
||||
|
||||
type AliYunOssConfig struct {
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
AccessKey string `json:"access_key,omitempty"`
|
||||
AccessSecret string `json:"access_secret,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
AccessKey string `json:"access_key,omitempty"`
|
||||
AccessSecret string `json:"access_secret,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ThumbTemplate string `json:"thumb_template,omitempty"` // 缩略图模板,使用{width}和{height}作为变量占位符,默认:?x-oss-process=image/resize,m_lfit,w_{width},h_{height}
|
||||
}
|
||||
|
||||
type LocalStorageConfig struct {
|
||||
BasePath string `json:"base_path,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
BasePath string `json:"base_path,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
ThumbTemplate string `json:"thumb_template,omitempty"` // 缩略图模板,使用{width}和{height}作为变量占位符,默认:?imageView2/4/w/{width}/h/{height}/q/75
|
||||
}
|
||||
|
||||
type TencentOssConfig struct {
|
||||
Region string `json:"region,omitempty"`
|
||||
SecretId string `json:"secret_id,omitempty"`
|
||||
SecretKey string `json:"secret_key,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ThumbTemplate string `json:"thumb_template,omitempty"` // 缩略图模板,使用{width}和{height}作为变量占位符,默认:?imageView2/1/w/{width}/h/{height}/format/jpg
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package types
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
// PPTImageProvider PPT 图片生成提供方
|
||||
type PPTImageProvider string
|
||||
|
||||
const (
|
||||
PPTImageProviderNanoBanana PPTImageProvider = "nano_banana"
|
||||
PPTImageProviderSeedream PPTImageProvider = "seedream"
|
||||
)
|
||||
|
||||
// PPTConfig PPT 生成配置(存储在 config 表,name = 'ppt')
|
||||
type PPTConfig struct {
|
||||
// 分镜 LLM 配置
|
||||
OutlineLLMApiURL string `json:"outline_llm_api_url"` // 分镜 LLM API 地址
|
||||
OutlineLLMApiKey string `json:"outline_llm_api_key"` // 分镜 LLM API Key
|
||||
OutlineLLMModel string `json:"outline_llm_model"` // 分镜 LLM 模型名称,如 gpt-4o-mini
|
||||
|
||||
// 图片生成通用配置
|
||||
ActiveImageProvider PPTImageProvider `json:"active_image_provider"` // 当前启用的图片模型提供方
|
||||
MaxSlidesPerTask int `json:"max_slides_per_task"` // 单个任务最多生成的 PPT 页数
|
||||
PowerCostPerSlide int `json:"power_cost_per_slide"` // 每张 PPT 图片消耗的算力
|
||||
MaxConcurrentRequests int `json:"max_concurrent_requests"` // 图片生成最大并发数
|
||||
QPSLimit int `json:"qps_limit"` // 外部图片 API 的 QPS 限制
|
||||
|
||||
// Nano Banana 配置
|
||||
NanoBananaApiURL string `json:"nano_banana_api_url"` // Nano Banana API 地址,如 https://xxx/v1/images/generations
|
||||
NanoBananaApiKey string `json:"nano_banana_api_key"` // Nano Banana API Key
|
||||
NanoBananaModel string `json:"nano_banana_model"` // 模型名称,如 nano-banana、nano-banana-hd
|
||||
NanoBananaResponseFormat string `json:"nano_banana_response_format"` // 响应格式:url 或 b64_json
|
||||
NanoBananaAspectRatio string `json:"nano_banana_aspect_ratio"` // 宽高比,如 1:1、4:3、16:9;空则默认 16:9
|
||||
|
||||
// Doubao Seedream 配置
|
||||
SeedreamBaseURL string `json:"seedream_base_url"` // Seedream base url,例如:https://ark.cn-beijing.volces.com/api/v3
|
||||
SeedreamApiKey string `json:"seedream_api_key"` // Seedream API Key(ARK_API_KEY)
|
||||
SeedreamModel string `json:"seedream_model"` // Seedream 模型 ID,例如:doubao-seedream-5-0-260128
|
||||
SeedreamSize string `json:"seedream_size"` // 图片尺寸 WxH(如 1920x1080)或 2K/4K 等;空则默认 1920x1080(16:9)
|
||||
SeedreamOutputFormat string `json:"seedream_output_format"` // 输出格式,例如:png
|
||||
SeedreamResponseType string `json:"seedream_response_format"` // 响应格式,例如:url
|
||||
SeedreamWatermark bool `json:"seedream_watermark"` // 是否开启水印
|
||||
}
|
||||
+14
-3
@@ -8,9 +8,10 @@ package types
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
type SMSConfig struct {
|
||||
Active string `json:"active,omitempty"`
|
||||
Ali SmsConfigAli `json:"aliyun,omitempty"`
|
||||
Bao SmsConfigBao `json:"bao,omitempty"`
|
||||
Active string `json:"active,omitempty"`
|
||||
Ali SmsConfigAli `json:"aliyun,omitempty"`
|
||||
Bao SmsConfigBao `json:"bao,omitempty"`
|
||||
Tencent SmsConfigTencent `json:"tencent,omitempty"`
|
||||
}
|
||||
|
||||
// SmsConfigAli 阿里云短信平台配置
|
||||
@@ -28,3 +29,13 @@ type SmsConfigBao struct {
|
||||
Sign string `json:"sign,omitempty"` // 短信签名
|
||||
CodeTemplate string `json:"code_template,omitempty"` // 验证码短信模板 匹配
|
||||
}
|
||||
|
||||
// SmsConfigTencent 腾讯云短信平台配置
|
||||
type SmsConfigTencent struct {
|
||||
SecretId string `json:"secret_id,omitempty"` // 腾讯云 SecretId
|
||||
SecretKey string `json:"secret_key,omitempty"` // 腾讯云 SecretKey
|
||||
SmsSdkAppId string `json:"sms_sdk_app_id,omitempty"` // 短信应用ID
|
||||
Sign string `json:"sign,omitempty"` // 短信签名
|
||||
CodeTempId string `json:"code_temp_id,omitempty"` // 验证码短信模板ID
|
||||
Region string `json:"region,omitempty"` // 地区,默认 ap-guangzhou
|
||||
}
|
||||
|
||||
+20
-83
@@ -20,6 +20,7 @@ const (
|
||||
TaskSwapFace = TaskType("swapFace")
|
||||
TaskUpscale = TaskType("upscale")
|
||||
TaskVariation = TaskType("variation")
|
||||
TaskModal = TaskType("modal") // 局部重绘
|
||||
)
|
||||
|
||||
// MjTask MidJourney 任务
|
||||
@@ -38,43 +39,17 @@ type MjTask struct {
|
||||
ChannelId string `json:"channel_id"` // 渠道ID,用来区分是哪个渠道创建的任务,一个任务的 create 和 action 操作必须要再同一个渠道
|
||||
Mode string `json:"mode"` // 绘画模式,relax, fast, turbo
|
||||
TranslateModelId int `json:"translate_model_id"` // 提示词翻译模型ID
|
||||
MaskBase64 string `json:"mask_base64,omitempty"` // 局部重绘蒙版 base64(仅 TaskModal 使用)
|
||||
}
|
||||
|
||||
type SdTask struct {
|
||||
Id int `json:"id"` // job 数据库ID
|
||||
Type TaskType `json:"type"`
|
||||
UserId int `json:"user_id"`
|
||||
Params SdTaskParams `json:"params"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
TranslateModelId int `json:"translate_model_id"` // 提示词翻译模型ID
|
||||
}
|
||||
|
||||
type SdTaskParams struct {
|
||||
TaskId string `json:"task_id"`
|
||||
Prompt string `json:"prompt"` // 提示词
|
||||
NegPrompt string `json:"neg_prompt"` // 反向提示词
|
||||
Steps int `json:"steps"` // 迭代步数,默认20
|
||||
Sampler string `json:"sampler"` // 采样器
|
||||
Scheduler string `json:"scheduler"` // 采样调度
|
||||
FaceFix bool `json:"face_fix"` // 面部修复
|
||||
CfgScale float32 `json:"cfg_scale"` //引导系数,默认 7
|
||||
Seed int64 `json:"seed"` // 随机数种子
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
HdFix bool `json:"hd_fix"` // 启用高清修复
|
||||
HdRedrawRate float32 `json:"hd_redraw_rate"` // 高清修复重绘幅度
|
||||
HdScale int `json:"hd_scale"` // 放大倍数
|
||||
HdScaleAlg string `json:"hd_scale_alg"` // 放大算法
|
||||
HdSteps int `json:"hd_steps"` // 高清修复迭代步数
|
||||
}
|
||||
|
||||
// DallTask DALL-E task
|
||||
type DallTask struct {
|
||||
// ImageTask Image generation task
|
||||
type ImageTask struct {
|
||||
ModelId uint `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
ModelValue string `json:"model_value"`
|
||||
Image []string `json:"image,omitempty"`
|
||||
Id uint `json:"id"`
|
||||
TaskId string `json:"task_id"` // Kapon 异步任务 ID,与 API 返回的 task_id 一致
|
||||
UserId uint `json:"user_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
AspectRatio string `json:"aspect_ratio"`
|
||||
@@ -102,60 +77,22 @@ type SunoTask struct {
|
||||
}
|
||||
|
||||
const (
|
||||
VideoLuma = "luma"
|
||||
VideoRunway = "runway"
|
||||
VideoCog = "cog"
|
||||
VideoKeLing = "keling"
|
||||
VideoLuma = "luma"
|
||||
VideoSora = "sora"
|
||||
VideoVeo = "veo"
|
||||
VideoKeLing = "keling"
|
||||
VideoMiniMax = "minimax"
|
||||
VideoWan = "wan"
|
||||
VideoDoubao = "doubao"
|
||||
)
|
||||
|
||||
type VideoTask struct {
|
||||
Id uint `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
UserId int `json:"user_id"`
|
||||
Type string `json:"type"`
|
||||
TaskId string `json:"task_id"`
|
||||
Prompt string `json:"prompt"` // 提示词
|
||||
Params interface{} `json:"params"`
|
||||
TranslateModelId int `json:"translate_model_id"` // 提示词翻译模型ID
|
||||
}
|
||||
|
||||
type LumaVideoParams struct {
|
||||
PromptOptimize bool `json:"prompt_optimize"` // 是否优化提示词
|
||||
Loop bool `json:"loop"` // 是否循环参考图
|
||||
StartImgURL string `json:"start_img_url"` // 第一帧参考图地址
|
||||
EndImgURL string `json:"end_img_url"` // 最后一帧参考图地址
|
||||
Model string `json:"model"` // 使用哪个模型生成视频
|
||||
Radio string `json:"radio"` // 视频尺寸
|
||||
Style string `json:"style"` // 风格
|
||||
Duration int `json:"duration"` // 视频时长(秒)
|
||||
}
|
||||
|
||||
type KeLingVideoParams struct {
|
||||
TaskType string `json:"task_type"` // 任务类型: text2video/image2video
|
||||
Model string `json:"model"` // 模型: default/anime
|
||||
Prompt string `json:"prompt"` // 视频描述
|
||||
NegPrompt string `json:"negative_prompt"` // 负面提示词
|
||||
CfgScale float64 `json:"cfg_scale"` // 相关性系数(0-1)
|
||||
Mode string `json:"mode"` // 生成模式: std/pro
|
||||
AspectRatio string `json:"aspect_ratio"` // 画面比例: 16:9/9:16/1:1
|
||||
Duration string `json:"duration"` // 视频时长: 5/10
|
||||
CameraControl CameraControl `json:"camera_control"` // 摄像机控制
|
||||
Image string `json:"image"` // 参考图片URL(image2video)
|
||||
ImageTail string `json:"image_tail"` // 尾帧图片URL(image2video)
|
||||
}
|
||||
|
||||
// CameraControl 摄像机控制
|
||||
type CameraControl struct {
|
||||
Type string `json:"type"` // 控制类型: simple/down_back/forward_up/right_turn_forward/left_turn_forward
|
||||
Config CameraConfig `json:"config"` // 控制参数(仅simple类型时使用)
|
||||
}
|
||||
|
||||
// CameraConfig 摄像机参数
|
||||
type CameraConfig struct {
|
||||
Horizontal int `json:"horizontal"` // 水平移动(-10到10)
|
||||
Vertical int `json:"vertical"` // 垂直移动(-10到10)
|
||||
Pan int `json:"pan"` // 左右旋转(-10到10)
|
||||
Tilt int `json:"tilt"` // 上下旋转(-10到10)
|
||||
Roll int `json:"roll"` // 横向翻转(-10到10)
|
||||
Zoom int `json:"zoom"` // 镜头缩放(-10到10)
|
||||
Id uint `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
UserId int `json:"user_id"`
|
||||
Type string `json:"type"` // provider(不带版本号:veo, sora, luma)
|
||||
TaskId string `json:"task_id"`
|
||||
Prompt string `json:"prompt"` // 提示词
|
||||
Params any `json:"params"`
|
||||
TranslateModelId int `json:"translate_model_id"` // 提示词翻译模型ID
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package types
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
// VideoConfig 视频生成配置(存储在 config 表,name = 'video')
|
||||
type VideoConfig struct {
|
||||
ApiURL string `json:"api_url"` // API 地址
|
||||
ApiKey string `json:"api_key"` // API 密钥
|
||||
VideoPowers map[string]VideoModelPower `json:"video_powers"` // 模型算力配置
|
||||
}
|
||||
|
||||
// VideoModelPower 单个模型的算力配置
|
||||
// PowerConfig 说明:
|
||||
// 新的价格配置方式:根据模型的 priceParams 生成笛卡尔乘积,每个组合对应一个价格
|
||||
// 固定价格示例:{"fixed": 20}
|
||||
// 多参数组合示例:{"5_720P": 10, "5_1080P": 20, "10_720P": 10, "10_1080P": 40}
|
||||
// 复杂组合示例:{"std_5_sound": 10, "std_5_silent": 5, "pro_10_sound": 20, "pro_10_silent": 10}
|
||||
type VideoModelPower struct {
|
||||
Provider string `json:"provider"` // 服务提供商(不带版本号:veo, sora, luma)
|
||||
Model string `json:"model"` // 模型名称(带版本号:veo-2.0, sora-2.0)
|
||||
PowerConfig map[string]int `json:"power_config"` // 算力配置(基于 priceParams 的笛卡尔乘积)
|
||||
ApiKeyType string `json:"api_key_type"` // ApiKey 表的 type 字段(可选,用于多 API Key 场景)
|
||||
}
|
||||
|
||||
// 视频任务状态常量
|
||||
const (
|
||||
VideoStatusPending = "pending" // 等待处理
|
||||
VideoStatusInProgress = "in_progress" // 处理中
|
||||
VideoStatusDownloading = "downloading" // 视频下载中
|
||||
VideoStatusSuccess = "success" // 成功
|
||||
VideoStatusFailed = "failed" // 失败
|
||||
)
|
||||
@@ -36,9 +36,8 @@ const (
|
||||
|
||||
ChPing = WsChannel("ping")
|
||||
ChChat = WsChannel("chat")
|
||||
ChMj = WsChannel("mj")
|
||||
ChSd = WsChannel("sd")
|
||||
ChDall = WsChannel("dall")
|
||||
ChMj = WsChannel("mj")
|
||||
ChImage = WsChannel("image")
|
||||
ChSuno = WsChannel("suno")
|
||||
ChLuma = WsChannel("luma")
|
||||
ChKeLing = WsChannel("keling")
|
||||
|
||||
+26
-12
@@ -1,8 +1,6 @@
|
||||
module geekai
|
||||
|
||||
go 1.21
|
||||
|
||||
toolchain go1.22.4
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.1.0
|
||||
@@ -18,6 +16,7 @@ require (
|
||||
github.com/pkoukk/tiktoken-go v0.1.1-0.20230418101013-cae809389480
|
||||
github.com/qiniu/go-sdk/v7 v7.17.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.47
|
||||
github.com/volcengine/volc-sdk-golang v1.0.23
|
||||
go.uber.org/zap v1.23.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -28,17 +27,24 @@ require (
|
||||
github.com/go-pay/gopay v1.5.101
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/google/go-tika v0.3.1
|
||||
github.com/jung-kurt/gofpdf/v2 v2.17.3
|
||||
github.com/ktye/pptx v0.0.0-20250326170941-2a6bc4329df6
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/sashabaranov/go-openai v1.38.1
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||
github.com/shopspring/decimal v1.3.1
|
||||
github.com/syndtr/goleveldb v1.0.0
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.49
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.1.49
|
||||
github.com/volcengine/volcengine-go-sdk v1.1.34
|
||||
golang.org/x/image v0.15.0
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
golang.org/x/image v0.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-pay/crypto v0.0.1 // indirect
|
||||
github.com/go-pay/errgroup v0.0.2 // indirect
|
||||
@@ -46,9 +52,17 @@ require (
|
||||
github.com/go-pay/xlog v0.0.2 // indirect
|
||||
github.com/go-pay/xtime v0.0.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-querystring v1.0.0 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.3 // indirect
|
||||
github.com/mozillazg/go-httpheader v0.2.1 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.13 // indirect
|
||||
github.com/tklauser/numcpus v0.7.0 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/mock v0.4.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
@@ -91,12 +105,12 @@ require (
|
||||
go.uber.org/dig v1.16.1 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.21.0 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.5.0
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
@@ -116,7 +130,7 @@ require (
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/fx v1.19.3
|
||||
go.uber.org/multierr v1.7.0 // indirect
|
||||
golang.org/x/crypto v0.23.0
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
gorm.io/gorm v1.25.1
|
||||
)
|
||||
|
||||
+56
-17
@@ -2,6 +2,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
|
||||
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.405 h1:cKNFQmeCQFN0WNfjScKoVrGi7vXxTVbkCvCqSrOf+P4=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.405/go.mod h1:Api2AkmMgGaSUAhmk76oaFObkoeCPc/bKAqcyplPODs=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible h1:Sg/2xHwDrioHpxTN6WMiwbXTpUEinBpHsN7mG21Rc2k=
|
||||
@@ -11,6 +12,8 @@ github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
|
||||
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
@@ -22,11 +25,14 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.8.1 h1:6Lcdwya6GjPUNsBct8Lg/yRPwMhABj269AAzdGSiR+0=
|
||||
@@ -114,11 +120,14 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-tika v0.3.1 h1:l+jr10hDhZjcgxFRfcQChRLo1bPXQeLFluMyvDhXTTA=
|
||||
github.com/google/go-tika v0.3.1/go.mod h1:DJh5N8qxXIl85QkqmXknd+PeeRkUOTbvwyYf7ieDz6c=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 h1:hR7/MlvK23p6+lIw9SN1TigNLn9ZnF3W4SYRKq2gAHs=
|
||||
github.com/google/pprof v0.0.0-20230602150820-91b7bce49751/go.mod h1:Jh3hGz2jkYak8qXPD19ryItVnUgpgeqzdkY/D0EaeuA=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
@@ -146,6 +155,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC
|
||||
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY=
|
||||
github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU=
|
||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -161,6 +172,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ktye/pptx v0.0.0-20250326170941-2a6bc4329df6 h1:pItMMIM7cHfp43QyuZFeYJyKHPk4yFs8JQUIdjlcWYo=
|
||||
github.com/ktye/pptx v0.0.0-20250326170941-2a6bc4329df6/go.mod h1:X+eOu1OuD+D/IKBa+1HnJBcSaKBB75n5TmKkFVFDPU8=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
@@ -176,12 +189,16 @@ github.com/minio/minio-go/v7 v7.0.62 h1:qNYsFZHEzl+NfH8UxW4jpmlKav1qUAgfY30YNRne
|
||||
github.com/minio/minio-go/v7 v7.0.62/go.mod h1:Q6X7Qjb7WMhvG65qKf4gUgA5XaiSox74kR1uAEjxRS4=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
|
||||
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
|
||||
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
@@ -217,6 +234,11 @@ github.com/quic-go/quic-go v0.45.0 h1:OHmkQGM37luZITyTSu6ff03HP/2IrwDX1ZFiNEhSFU
|
||||
github.com/quic-go/quic-go v0.45.0/go.mod h1:1dLehS7TIR64+vxGR70GDcatWTOtMX2PUtnKsjbTurI=
|
||||
github.com/refraction-networking/utls v1.3.2 h1:o+AkWB57mkcoW36ET7uJ002CpBWHu0KPxi6vzxvPnv8=
|
||||
github.com/refraction-networking/utls v1.3.2/go.mod h1:fmoaOww2bxzzEpIKOebIsnBvjQpqP7L2vcm/9KUfm/E=
|
||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
@@ -243,10 +265,21 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.49 h1:BQwUw2V21zIRJxstnaxtG/22lBL3+FbUgWhaC6Qd9ws=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.49/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.1.49 h1:8mlcG8TmoeEIDQGLYvqc9fdBQrNwKEb56I2HVNy9jdw=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.1.49/go.mod h1:YAdTku4GgK5H2LKea4bPgWf3qFLBibIBzgU1LW5WgEc=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.47 h1:uoS4Sob16qEYoapkqJq1D1Vnsy9ira9BfNUMtoFYTI4=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.47/go.mod h1:DH9US8nB+AJXqwu/AMOrCFN1COv3dpytXuJWHgdg7kE=
|
||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4=
|
||||
github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0=
|
||||
github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4=
|
||||
@@ -263,6 +296,12 @@ github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9
|
||||
github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU=
|
||||
github.com/volcengine/volcengine-go-sdk v1.1.34 h1:ha90JycCCTJNCse0UDziBgBsuX98ITOrkwYlDWcm7NI=
|
||||
github.com/volcengine/volcengine-go-sdk v1.1.34/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
@@ -290,20 +329,20 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
||||
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -317,16 +356,16 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -343,8 +382,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -361,8 +400,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -373,8 +412,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
|
||||
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
"geekai/handler"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"geekai/service"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
const SuperUsername = "admin"
|
||||
|
||||
@@ -293,7 +293,7 @@ func (h *ManagerHandler) ResetPass(c *gin.Context) {
|
||||
|
||||
password := utils.GenPassword(data.Password, user.Salt)
|
||||
user.Password = password
|
||||
res = h.DB.Updates(&user)
|
||||
res = h.DB.Model(&model.AdminUser{}).Where("id", data.Id).UpdateColumn("password", password)
|
||||
if res.Error != nil {
|
||||
resp.ERROR(c, res.Error.Error())
|
||||
return
|
||||
|
||||
@@ -8,7 +8,6 @@ package admin
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
@@ -59,15 +58,14 @@ func (h *ChatAppHandler) Save(c *gin.Context) {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
// 管理后台创建/编辑的 Gem 均为系统内置
|
||||
role.UserId = 0
|
||||
if data.SystemPrompt != "" {
|
||||
role.SystemPrompt = data.SystemPrompt
|
||||
}
|
||||
role.Id = data.Id
|
||||
if data.CreatedAt > 0 {
|
||||
role.CreatedAt = time.Unix(data.CreatedAt, 0)
|
||||
} else {
|
||||
err = h.DB.Where("marker", data.Key).First(&role).Error
|
||||
if err == nil {
|
||||
resp.ERROR(c, fmt.Sprintf("角色 %s 已存在", data.Key))
|
||||
return
|
||||
}
|
||||
}
|
||||
err = h.DB.Save(&role).Error
|
||||
if err != nil {
|
||||
@@ -83,7 +81,8 @@ func (h *ChatAppHandler) Save(c *gin.Context) {
|
||||
func (h *ChatAppHandler) List(c *gin.Context) {
|
||||
var items []model.ChatApp
|
||||
var roles = make([]vo.ChatApp, 0)
|
||||
res := h.DB.Order("sort_num ASC").Find(&items)
|
||||
// 仅展示系统内置智能体(user_id = 0),不展示用户自行创建的智能体
|
||||
res := h.DB.Where("user_id = 0").Order("sort_num ASC").Find(&items)
|
||||
if res.Error != nil {
|
||||
resp.ERROR(c, "No data found")
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ package admin
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
@@ -35,6 +36,7 @@ type ConfigHandler struct {
|
||||
smtpService *service.SmtpService
|
||||
captchaService *service.CaptchaService
|
||||
wxLoginService *service.WxLoginService
|
||||
wxGzhService *service.WxGzhService
|
||||
}
|
||||
|
||||
func NewConfigHandler(
|
||||
@@ -49,6 +51,7 @@ func NewConfigHandler(
|
||||
smtpService *service.SmtpService,
|
||||
captchaService *service.CaptchaService,
|
||||
wxLoginService *service.WxLoginService,
|
||||
wxChatService *service.WxGzhService,
|
||||
) *ConfigHandler {
|
||||
return &ConfigHandler{
|
||||
BaseHandler: handler.BaseHandler{App: app, DB: db},
|
||||
@@ -61,6 +64,7 @@ func NewConfigHandler(
|
||||
smtpService: smtpService,
|
||||
captchaService: captchaService,
|
||||
wxLoginService: wxLoginService,
|
||||
wxGzhService: wxChatService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +88,7 @@ func (h *ConfigHandler) RegisterRoutes() {
|
||||
rg.POST("update/oss", h.UpdateOss)
|
||||
rg.POST("update/smtp", h.UpdateStmp)
|
||||
rg.GET("get", h.Get)
|
||||
rg.POST("update/wx_gzh", h.UpdateWxGzh)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,15 +115,18 @@ func (h *ConfigHandler) UpdateBase(c *gin.Context) {
|
||||
// UpdatePower 更新系统配置
|
||||
func (h *ConfigHandler) UpdatePower(c *gin.Context) {
|
||||
var data struct {
|
||||
InitPower int `json:"init_power,omitempty"` // 新用户注册赠送算力值
|
||||
DailyPower int `json:"daily_power,omitempty"` // 每日签到赠送算力
|
||||
InvitePower int `json:"invite_power,omitempty"` // 邀请新用户赠送算力值
|
||||
MjPower int `json:"mj_power,omitempty"` // MJ 绘画消耗算力
|
||||
MjActionPower int `json:"mj_action_power,omitempty"` // MJ 操作(放大,变换)消耗算力
|
||||
SdPower int `json:"sd_power,omitempty"` // SD 绘画消耗算力
|
||||
SunoPower int `json:"suno_power,omitempty"` // Suno 生成歌曲消耗算力
|
||||
LumaPower int `json:"luma_power,omitempty"` // Luma 生成视频消耗算力
|
||||
KeLingPowers map[string]int `json:"keling_powers,omitempty"` // 可灵生成视频消耗算力
|
||||
InitPower int `json:"init_power,omitempty"` // 新用户注册赠送算力值
|
||||
DailyPower int `json:"daily_power,omitempty"` // 每日签到赠送算力
|
||||
InvitePower int `json:"invite_power,omitempty"` // 邀请新用户赠送算力值
|
||||
MjPower int `json:"mj_power,omitempty"` // MJ 绘画消耗算力
|
||||
MjActionPower int `json:"mj_action_power,omitempty"` // MJ 操作(放大,变换)消耗算力
|
||||
MjUpscalePower int `json:"mj_upscale_power,omitempty"` // MJ 放大/变换消耗算力
|
||||
MjBlendPower int `json:"mj_blend_power,omitempty"` // MJ 融图消耗算力
|
||||
MjSwapFacePower int `json:"mj_swap_face_power,omitempty"` // MJ 换脸消耗算力
|
||||
MjModalPower int `json:"mj_modal_power,omitempty"` // MJ 局部重绘消耗算力
|
||||
SunoPower int `json:"suno_power,omitempty"` // Suno 生成歌曲消耗算力
|
||||
LumaPower int `json:"luma_power,omitempty"` // Luma 生成视频消耗算力
|
||||
KeLingPowers map[string]int `json:"keling_powers,omitempty"` // 可灵生成视频消耗算力
|
||||
}
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
@@ -130,7 +138,10 @@ func (h *ConfigHandler) UpdatePower(c *gin.Context) {
|
||||
h.sysConfig.Base.InvitePower = data.InvitePower
|
||||
h.sysConfig.Base.MjPower = data.MjPower
|
||||
h.sysConfig.Base.MjActionPower = data.MjActionPower
|
||||
h.sysConfig.Base.SdPower = data.SdPower
|
||||
h.sysConfig.Base.MjUpscalePower = data.MjUpscalePower
|
||||
h.sysConfig.Base.MjBlendPower = data.MjBlendPower
|
||||
h.sysConfig.Base.MjSwapFacePower = data.MjSwapFacePower
|
||||
h.sysConfig.Base.MjModalPower = data.MjModalPower
|
||||
h.sysConfig.Base.SunoPower = data.SunoPower
|
||||
h.sysConfig.Base.LumaPower = data.LumaPower
|
||||
h.sysConfig.Base.KeLingPowers = data.KeLingPowers
|
||||
@@ -374,14 +385,19 @@ func (h *ConfigHandler) Update(name string, value any) error {
|
||||
func (h *ConfigHandler) Get(c *gin.Context) {
|
||||
name := c.Query("key")
|
||||
var config model.Config
|
||||
res := h.DB.Where("name", name).First(&config)
|
||||
if res.Error != nil {
|
||||
resp.ERROR(c, res.Error.Error())
|
||||
err := h.DB.Where("name", name).First(&config).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.SUCCESS(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var value map[string]any
|
||||
err := utils.JsonDecode(config.Value, &value)
|
||||
err = utils.JsonDecode(config.Value, &value)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
@@ -389,3 +405,20 @@ func (h *ConfigHandler) Get(c *gin.Context) {
|
||||
|
||||
resp.SUCCESS(c, value)
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) UpdateWxGzh(c *gin.Context) {
|
||||
var data types.WxGzhConfig
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
err := h.Update(types.ConfigKeyWxGzh, data)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.wxGzhService.UpdateConfig(data)
|
||||
h.sysConfig.WxGzh = data
|
||||
resp.SUCCESS(c, data)
|
||||
}
|
||||
|
||||
@@ -123,22 +123,20 @@ func (h *DashboardHandler) Stats(c *gin.Context) {
|
||||
h.DB.Model(&model.Order{}).Where("status = ?", types.OrderPaidSuccess).Where("created_at > ?", zeroTime).Count(&stats.TodayOrders)
|
||||
|
||||
// 图片生成任务统计
|
||||
var mjJobs, sdJobs, dallJobs, jimengImageJobs int64
|
||||
var mjJobs, imageJobs, jimengImageJobs int64
|
||||
h.DB.Model(&model.MidJourneyJob{}).Count(&mjJobs)
|
||||
h.DB.Model(&model.SdJob{}).Count(&sdJobs)
|
||||
h.DB.Model(&model.DallJob{}).Count(&dallJobs)
|
||||
h.DB.Model(&model.ImageJob{}).Count(&imageJobs)
|
||||
h.DB.Model(&model.JimengJob{}).Where("type IN ?", []string{"text_to_image", "image_to_image", "image_edit", "image_effects"}).Count(&jimengImageJobs)
|
||||
stats.ImageJobs = mjJobs + sdJobs + dallJobs + jimengImageJobs
|
||||
stats.ImageJobs = mjJobs + imageJobs + jimengImageJobs
|
||||
|
||||
logger.Info("stats.ImageJobs", stats.ImageJobs)
|
||||
|
||||
// 今日图片生成任务统计
|
||||
var todayMjJobs, todaySdJobs, todayDallJobs, todayJimengImageJobs int64
|
||||
var todayMjJobs, todayImageJobs, todayJimengImageJobs int64
|
||||
h.DB.Model(&model.MidJourneyJob{}).Where("created_at > ?", zeroTime).Count(&todayMjJobs)
|
||||
h.DB.Model(&model.SdJob{}).Where("created_at > ?", zeroTime).Count(&todaySdJobs)
|
||||
h.DB.Model(&model.DallJob{}).Where("created_at > ?", zeroTime).Count(&todayDallJobs)
|
||||
h.DB.Model(&model.ImageJob{}).Where("created_at > ?", zeroTime).Count(&todayImageJobs)
|
||||
h.DB.Model(&model.JimengJob{}).Where("type IN ?", []string{"text_to_image", "image_to_image", "image_edit", "image_effects"}).Where("created_at > ?", zeroTime).Count(&todayJimengImageJobs)
|
||||
stats.TodayImageJobs = todayMjJobs + todaySdJobs + todayDallJobs + todayJimengImageJobs
|
||||
stats.TodayImageJobs = todayMjJobs + todayImageJobs + todayJimengImageJobs
|
||||
|
||||
// 视频生成任务统计
|
||||
var videoJobs, jimengVideoJobs int64
|
||||
|
||||
@@ -42,8 +42,7 @@ func (h *ImageHandler) RegisterRoutes() {
|
||||
group.Use(middleware.AdminAuthMiddleware(h.App.Config.AdminSession.SecretKey, h.App.Redis))
|
||||
{
|
||||
group.POST("list/mj", h.MjList)
|
||||
group.POST("list/sd", h.SdList)
|
||||
group.POST("list/dall", h.DallList)
|
||||
group.POST("list/image", h.ImageList)
|
||||
group.GET("remove", h.Remove)
|
||||
}
|
||||
}
|
||||
@@ -100,8 +99,8 @@ func (h *ImageHandler) MjList(c *gin.Context) {
|
||||
resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items))
|
||||
}
|
||||
|
||||
// SdList Stable Diffusion 任务列表
|
||||
func (h *ImageHandler) SdList(c *gin.Context) {
|
||||
// ImageList Image generation 任务列表
|
||||
func (h *ImageHandler) ImageList(c *gin.Context) {
|
||||
var data imageQuery
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
@@ -123,59 +122,15 @@ func (h *ImageHandler) SdList(c *gin.Context) {
|
||||
session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1])
|
||||
}
|
||||
var total int64
|
||||
session.Model(&model.SdJob{}).Count(&total)
|
||||
var list []model.SdJob
|
||||
var items = make([]vo.SdJob, 0)
|
||||
session.Model(&model.ImageJob{}).Count(&total)
|
||||
var list []model.ImageJob
|
||||
var items = make([]vo.ImageJob, 0)
|
||||
offset := (data.Page - 1) * data.PageSize
|
||||
err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error
|
||||
if err == nil {
|
||||
// 填充数据
|
||||
for _, item := range list {
|
||||
var job vo.SdJob
|
||||
err = utils.CopyObject(item, &job)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
job.CreatedAt = item.CreatedAt.Unix()
|
||||
items = append(items, job)
|
||||
}
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items))
|
||||
}
|
||||
|
||||
// DallList DALL-E 任务列表
|
||||
func (h *ImageHandler) DallList(c *gin.Context) {
|
||||
var data imageQuery
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
session := h.DB.Session(&gorm.Session{})
|
||||
if data.Username != "" {
|
||||
var user model.User
|
||||
err := h.DB.Where("username", data.Username).First(&user).Error
|
||||
if err == nil {
|
||||
session = session.Where("user_id", user.Id)
|
||||
}
|
||||
}
|
||||
if data.Prompt != "" {
|
||||
session = session.Where("prompt LIKE ?", "%"+data.Prompt+"%")
|
||||
}
|
||||
if len(data.CreatedAt) == 2 {
|
||||
session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1])
|
||||
}
|
||||
var total int64
|
||||
session.Model(&model.DallJob{}).Count(&total)
|
||||
var list []model.DallJob
|
||||
var items = make([]vo.DallJob, 0)
|
||||
offset := (data.Page - 1) * data.PageSize
|
||||
err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error
|
||||
if err == nil {
|
||||
// 填充数据
|
||||
for _, item := range list {
|
||||
var job vo.DallJob
|
||||
var job vo.ImageJob
|
||||
err = utils.CopyObject(item, &job)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -209,8 +164,8 @@ func (h *ImageHandler) Remove(c *gin.Context) {
|
||||
remark = fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
progress = job.Progress
|
||||
imgURL = job.ImgURL
|
||||
case "sd":
|
||||
var job model.SdJob
|
||||
case "image":
|
||||
var job model.ImageJob
|
||||
if res := h.DB.Where("id", id).First(&job); res.Error != nil {
|
||||
resp.ERROR(c, "记录不存在")
|
||||
return
|
||||
@@ -218,22 +173,7 @@ func (h *ImageHandler) Remove(c *gin.Context) {
|
||||
|
||||
// 删除任务
|
||||
tx.Delete(&job)
|
||||
md = "stable-diffusion"
|
||||
power = job.Power
|
||||
userId = int(job.UserId)
|
||||
remark = fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
progress = job.Progress
|
||||
imgURL = job.ImgURL
|
||||
case "dall":
|
||||
var job model.DallJob
|
||||
if res := h.DB.Where("id", id).First(&job); res.Error != nil {
|
||||
resp.ERROR(c, "记录不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
tx.Delete(&job)
|
||||
md = "dall-e-3"
|
||||
md = "image-generation"
|
||||
power = job.Power
|
||||
userId = int(job.UserId)
|
||||
remark = fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
package admin
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
"geekai/handler"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MediaHandler struct {
|
||||
handler.BaseHandler
|
||||
userService *service.UserService
|
||||
uploader *oss.UploaderManager
|
||||
}
|
||||
|
||||
func NewMediaHandler(app *core.AppServer, db *gorm.DB, userService *service.UserService, manager *oss.UploaderManager) *MediaHandler {
|
||||
return &MediaHandler{BaseHandler: handler.BaseHandler{App: app, DB: db}, userService: userService, uploader: manager}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *MediaHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/admin/media/")
|
||||
|
||||
// 需要管理员授权的接口
|
||||
group.Use(middleware.AdminAuthMiddleware(h.App.Config.AdminSession.SecretKey, h.App.Redis))
|
||||
{
|
||||
group.POST("suno", h.SunoList)
|
||||
group.POST("videos", h.Videos)
|
||||
group.GET("remove", h.Remove)
|
||||
}
|
||||
}
|
||||
|
||||
type mediaQuery struct {
|
||||
Type string `json:"type"` // 任务类型 luma, keling
|
||||
Prompt string `json:"prompt"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt []string `json:"created_at"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// SunoList Suno 任务列表
|
||||
func (h *MediaHandler) SunoList(c *gin.Context) {
|
||||
var data mediaQuery
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
session := h.DB.Session(&gorm.Session{})
|
||||
if data.Username != "" {
|
||||
var user model.User
|
||||
err := h.DB.Where("username", data.Username).First(&user).Error
|
||||
if err == nil {
|
||||
session = session.Where("user_id", user.Id)
|
||||
}
|
||||
}
|
||||
if data.Prompt != "" {
|
||||
session = session.Where("prompt LIKE ?", "%"+data.Prompt+"%")
|
||||
}
|
||||
if len(data.CreatedAt) == 2 {
|
||||
session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1])
|
||||
}
|
||||
var total int64
|
||||
session.Model(&model.SunoJob{}).Count(&total)
|
||||
var list []model.SunoJob
|
||||
var items = make([]vo.SunoJob, 0)
|
||||
offset := (data.Page - 1) * data.PageSize
|
||||
err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error
|
||||
if err == nil {
|
||||
// 填充数据
|
||||
for _, item := range list {
|
||||
var job vo.SunoJob
|
||||
err = utils.CopyObject(item, &job)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
job.CreatedAt = item.CreatedAt.Unix()
|
||||
items = append(items, job)
|
||||
}
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items))
|
||||
}
|
||||
|
||||
// Videos 视频任务列表
|
||||
func (h *MediaHandler) Videos(c *gin.Context) {
|
||||
var data mediaQuery
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
session := h.DB.Session(&gorm.Session{}).Where("type", data.Type)
|
||||
if data.Username != "" {
|
||||
var user model.User
|
||||
err := h.DB.Where("username", data.Username).First(&user).Error
|
||||
if err == nil {
|
||||
session = session.Where("user_id", user.Id)
|
||||
}
|
||||
}
|
||||
if data.Prompt != "" {
|
||||
session = session.Where("prompt LIKE ?", "%"+data.Prompt+"%")
|
||||
}
|
||||
if len(data.CreatedAt) == 2 {
|
||||
session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1])
|
||||
}
|
||||
var total int64
|
||||
session.Model(&model.VideoJob{}).Count(&total)
|
||||
var list []model.VideoJob
|
||||
var items = make([]vo.VideoJob, 0)
|
||||
offset := (data.Page - 1) * data.PageSize
|
||||
err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error
|
||||
if err == nil {
|
||||
// 填充数据
|
||||
for _, item := range list {
|
||||
var job vo.VideoJob
|
||||
err = utils.CopyObject(item, &job)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
job.CreatedAt = item.CreatedAt.Unix()
|
||||
if job.VideoURL == "" {
|
||||
job.VideoURL = job.WaterURL
|
||||
}
|
||||
items = append(items, job)
|
||||
}
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items))
|
||||
}
|
||||
|
||||
func (h *MediaHandler) Remove(c *gin.Context) {
|
||||
id := h.GetInt(c, "id", 0)
|
||||
tab := c.Query("tab")
|
||||
|
||||
tx := h.DB.Begin()
|
||||
var md, remark, fileURL string
|
||||
var power, userId, progress int
|
||||
switch tab {
|
||||
case "suno":
|
||||
var job model.SunoJob
|
||||
if err := h.DB.Where("id", id).First(&job).Error; err != nil {
|
||||
resp.ERROR(c, "记录不存在")
|
||||
return
|
||||
}
|
||||
tx.Delete(&job)
|
||||
md = "suno"
|
||||
power = job.Power
|
||||
userId = int(job.UserId)
|
||||
remark = fmt.Sprintf("SUNO 任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
progress = job.Progress
|
||||
fileURL = job.AudioURL
|
||||
case "luma":
|
||||
case "keling":
|
||||
var job model.VideoJob
|
||||
if res := h.DB.Where("id", id).First(&job); res.Error != nil {
|
||||
resp.ERROR(c, "记录不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
tx.Delete(&job)
|
||||
md = job.Type
|
||||
power = job.Power
|
||||
userId = int(job.UserId)
|
||||
remark = fmt.Sprintf("LUMA 任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
progress = job.Progress
|
||||
fileURL = job.VideoURL
|
||||
if fileURL == "" {
|
||||
fileURL = job.WaterURL
|
||||
}
|
||||
default:
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
if progress != 100 {
|
||||
err := h.userService.IncreasePower(uint(userId), power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: md,
|
||||
Remark: remark,
|
||||
})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
tx.Commit()
|
||||
// remove image
|
||||
err := h.uploader.GetUploadHandler().Delete(fileURL)
|
||||
if err != nil {
|
||||
logger.Error("remove image failed: ", err)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
@@ -212,12 +212,8 @@ func (h *ModerationHandler) GetSourceList(c *gin.Context) {
|
||||
"name": "Midjourney 绘图",
|
||||
},
|
||||
{
|
||||
"id": types.ModerationSourceDalle,
|
||||
"name": "Dalle 绘图",
|
||||
},
|
||||
{
|
||||
"id": types.ModerationSourceSD,
|
||||
"name": "StableDiffusion 绘图",
|
||||
"id": types.ModerationSourceImage,
|
||||
"name": "AI图像生成",
|
||||
},
|
||||
{
|
||||
"id": types.ModerationSourceSuno,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package admin
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
"geekai/handler"
|
||||
"geekai/service/ppt"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PPTHandler 管理后台 PPT 生成配置处理器
|
||||
type PPTHandler struct {
|
||||
handler.BaseHandler
|
||||
pptService *ppt.PptService
|
||||
}
|
||||
|
||||
// NewPPTHandler 创建管理后台 PPT 配置处理器
|
||||
func NewPPTHandler(app *core.AppServer, db *gorm.DB, pptService *ppt.PptService) *PPTHandler {
|
||||
return &PPTHandler{
|
||||
BaseHandler: handler.BaseHandler{App: app, DB: db},
|
||||
pptService: pptService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册 PPT 配置相关路由
|
||||
func (h *PPTHandler) RegisterRoutes() {
|
||||
rg := h.App.Engine.Group("/api/admin/ppt/")
|
||||
rg.Use(middleware.AdminAuthMiddleware(h.App.Config.AdminSession.SecretKey, h.App.Redis))
|
||||
{
|
||||
rg.GET("config", h.GetConfig)
|
||||
rg.POST("config/update", h.UpdateConfig)
|
||||
rg.GET("jobs", h.Jobs)
|
||||
rg.GET("jobs/:task_id", h.JobDetail)
|
||||
rg.GET("jobs/:task_id/export", h.ExportJob)
|
||||
rg.GET("stats", h.Stats)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig 获取 PPT 生成配置
|
||||
func (h *PPTHandler) GetConfig(c *gin.Context) {
|
||||
var cfg model.Config
|
||||
err := h.DB.Where("name", types.ConfigKeyPPT).First(&cfg).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 返回一个默认空配置
|
||||
resp.SUCCESS(c, types.PPTConfig{
|
||||
OutlineLLMModel: "gpt-4o-mini",
|
||||
MaxSlidesPerTask: 30,
|
||||
PowerCostPerSlide: 0,
|
||||
MaxConcurrentRequests: 3,
|
||||
QPSLimit: 1,
|
||||
NanoBananaModel: "nano-banana",
|
||||
NanoBananaAspectRatio: "16:9",
|
||||
SeedreamSize: "1920x1080",
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, "获取配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var pptConfig types.PPTConfig
|
||||
err = utils.JsonDecode(cfg.Value, &pptConfig)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "解析配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, pptConfig)
|
||||
}
|
||||
|
||||
// UpdateConfig 更新 PPT 生成配置
|
||||
func (h *PPTHandler) UpdateConfig(c *gin.Context) {
|
||||
var req types.PPTConfig
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.ERROR(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 基础校验
|
||||
if req.MaxSlidesPerTask <= 0 {
|
||||
resp.ERROR(c, "单个任务最多 PPT 页数必须大于 0")
|
||||
return
|
||||
}
|
||||
|
||||
if req.PowerCostPerSlide < 0 {
|
||||
resp.ERROR(c, "每张 PPT 图片消耗算力不能小于 0")
|
||||
return
|
||||
}
|
||||
|
||||
if req.MaxConcurrentRequests <= 0 {
|
||||
req.MaxConcurrentRequests = 3
|
||||
}
|
||||
if req.QPSLimit <= 0 {
|
||||
req.QPSLimit = 1
|
||||
}
|
||||
|
||||
// 根据当前生图提供方做必填校验
|
||||
switch req.ActiveImageProvider {
|
||||
case types.PPTImageProviderNanoBanana:
|
||||
if req.NanoBananaApiURL == "" {
|
||||
resp.ERROR(c, "Nano Banana API 地址不能为空")
|
||||
return
|
||||
}
|
||||
if req.NanoBananaApiKey == "" {
|
||||
resp.ERROR(c, "Nano Banana API Key 不能为空")
|
||||
return
|
||||
}
|
||||
case types.PPTImageProviderSeedream:
|
||||
if req.SeedreamBaseURL == "" {
|
||||
resp.ERROR(c, "Seedream Base URL 不能为空")
|
||||
return
|
||||
}
|
||||
if req.SeedreamApiKey == "" {
|
||||
resp.ERROR(c, "Seedream API Key 不能为空")
|
||||
return
|
||||
}
|
||||
if req.SeedreamModel == "" {
|
||||
resp.ERROR(c, "Seedream 模型 ID 不能为空")
|
||||
return
|
||||
}
|
||||
default:
|
||||
// 允许为空,未来可以扩展更多 provider
|
||||
}
|
||||
|
||||
value := utils.JsonEncode(&req)
|
||||
var cfg model.Config
|
||||
err := h.DB.Where("name", types.ConfigKeyPPT).First(&cfg).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
cfg.Name = types.ConfigKeyPPT
|
||||
cfg.Value = value
|
||||
if err = h.DB.Create(&cfg).Error; err != nil {
|
||||
resp.ERROR(c, "创建配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c, gin.H{"message": "配置创建成功"})
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, "获取配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Value = value
|
||||
if err = h.DB.Updates(&cfg).Error; err != nil {
|
||||
resp.ERROR(c, "更新配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, gin.H{"message": "配置更新成功"})
|
||||
}
|
||||
|
||||
// Jobs 管理后台查看 PPT 任务列表(内存任务)
|
||||
func (h *PPTHandler) Jobs(c *gin.Context) {
|
||||
page := h.GetInt(c, "page", 1)
|
||||
pageSize := h.GetInt(c, "page_size", 20)
|
||||
filterUserId := h.GetInt(c, "user_id", 0)
|
||||
status := h.GetTrim(c, "status")
|
||||
|
||||
filtered, total := h.pptService.ListAdminJobs(c.Request.Context(), page, pageSize, filterUserId, status)
|
||||
|
||||
jobs := make([]gin.H, 0, len(filtered))
|
||||
for _, t := range filtered {
|
||||
job := t.TaskSummaryMap()
|
||||
job["user_id"] = t.UserID
|
||||
job["error_message"] = t.ErrorMessage
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, gin.H{
|
||||
"jobs": jobs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func buildAdminPPTTaskDetail(task *ppt.Task) gin.H {
|
||||
percentage := 0
|
||||
if task.Total > 0 {
|
||||
percentage = int(float64(task.Completed) / float64(task.Total) * 100)
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"user_id": task.UserID,
|
||||
"status": task.Status,
|
||||
"progress": gin.H{
|
||||
"total_slides": task.Total,
|
||||
"completed_slides": task.Completed,
|
||||
"percentage": percentage,
|
||||
},
|
||||
"slides": task.Slides,
|
||||
"error_message": task.ErrorMessage,
|
||||
"content": task.Content,
|
||||
"prompt": task.Prompt,
|
||||
"title": task.Title,
|
||||
"thumb": task.Thumb,
|
||||
}
|
||||
}
|
||||
|
||||
// JobDetail 管理后台查看指定 PPT 任务详情
|
||||
func (h *PPTHandler) JobDetail(c *gin.Context) {
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
task, ok := h.pptService.GetTask(taskID)
|
||||
if !ok {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
return
|
||||
}
|
||||
h.pptService.EnsureTaskMeta(c.Request.Context(), task)
|
||||
resp.SUCCESS(c, buildAdminPPTTaskDetail(task))
|
||||
}
|
||||
|
||||
// ExportJob 管理后台导出 PPT 任务
|
||||
func (h *PPTHandler) ExportJob(c *gin.Context) {
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
ef, ok := ppt.ParseExportFormat(c.Query("format"))
|
||||
if !ok {
|
||||
resp.ERROR(c, "format 参数无效,支持 pdf 或 pptx")
|
||||
return
|
||||
}
|
||||
|
||||
task, exists := h.pptService.GetTask(taskID)
|
||||
if !exists {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
return
|
||||
}
|
||||
if task.Status != ppt.TaskStatusCompleted {
|
||||
resp.ERROR(c, "仅已完成任务可导出")
|
||||
return
|
||||
}
|
||||
|
||||
h.pptService.EnsureTaskMeta(c.Request.Context(), task)
|
||||
|
||||
ossCfg := types.OSSConfig{}
|
||||
if h.App.SysConfig != nil {
|
||||
ossCfg = h.App.SysConfig.OSS
|
||||
}
|
||||
data, err := ppt.BuildExportBytes(c.Request.Context(), task.Slides, ef, ossCfg, h.App.Config)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
base := ppt.SanitizeExportBaseName(task.Title, task.TaskID)
|
||||
filename := base + ppt.ExportFileExt(ef)
|
||||
c.Header("Content-Disposition", ppt.ContentDispositionAttachment(filename))
|
||||
c.Data(200, ppt.ExportMimeType(ef), data)
|
||||
}
|
||||
|
||||
// Stats PPT 任务统计信息
|
||||
func (h *PPTHandler) Stats(c *gin.Context) {
|
||||
total, completed, processing, failed, pending := h.pptService.Stats()
|
||||
|
||||
resp.SUCCESS(c, gin.H{
|
||||
"totalTasks": total,
|
||||
"completedTasks": completed,
|
||||
"processingTasks": processing,
|
||||
"failedTasks": failed,
|
||||
"pendingTasks": pending,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package admin
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
"geekai/handler"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SunoHandler struct {
|
||||
handler.BaseHandler
|
||||
userService *service.UserService
|
||||
uploader *oss.UploaderManager
|
||||
}
|
||||
|
||||
func NewSunoHandler(app *core.AppServer, db *gorm.DB, userService *service.UserService, manager *oss.UploaderManager) *SunoHandler {
|
||||
return &SunoHandler{BaseHandler: handler.BaseHandler{App: app, DB: db}, userService: userService, uploader: manager}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *SunoHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/admin/suno/")
|
||||
|
||||
// 需要管理员授权的接口
|
||||
group.Use(middleware.AdminAuthMiddleware(h.App.Config.AdminSession.SecretKey, h.App.Redis))
|
||||
{
|
||||
group.POST("list", h.SunoList)
|
||||
group.GET("remove", h.Remove)
|
||||
}
|
||||
}
|
||||
|
||||
type sunoQuery struct {
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
CreatedAt []string `json:"created_at"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// SunoList Suno 任务列表
|
||||
func (h *SunoHandler) SunoList(c *gin.Context) {
|
||||
var data sunoQuery
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
session := h.DB.Session(&gorm.Session{})
|
||||
if data.Title != "" {
|
||||
session = session.Where("title LIKE ?", "%"+data.Title+"%")
|
||||
}
|
||||
if data.Prompt != "" {
|
||||
// 同时查询 prompt 字段和 params JSON 字段中的 prompt
|
||||
session = session.Where("prompt LIKE ? OR JSON_EXTRACT(params, '$.prompt') LIKE ?", "%"+data.Prompt+"%", "%"+data.Prompt+"%")
|
||||
}
|
||||
if len(data.CreatedAt) == 2 {
|
||||
session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1])
|
||||
}
|
||||
var total int64
|
||||
session.Model(&model.SunoJob{}).Count(&total)
|
||||
var list []model.SunoJob
|
||||
var items = make([]vo.SunoJob, 0)
|
||||
offset := (data.Page - 1) * data.PageSize
|
||||
err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error
|
||||
if err == nil {
|
||||
// 填充数据
|
||||
for _, item := range list {
|
||||
var job vo.SunoJob
|
||||
err = utils.CopyObject(item, &job)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
job.CreatedAt = item.CreatedAt.Unix()
|
||||
items = append(items, job)
|
||||
}
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items))
|
||||
}
|
||||
|
||||
func (h *SunoHandler) Remove(c *gin.Context) {
|
||||
id := h.GetInt(c, "id", 0)
|
||||
|
||||
tx := h.DB.Begin()
|
||||
var job model.SunoJob
|
||||
if err := h.DB.Where("id", id).First(&job).Error; err != nil {
|
||||
resp.ERROR(c, "记录不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
tx.Delete(&job)
|
||||
md := "suno"
|
||||
power := job.Power
|
||||
userId := int(job.UserId)
|
||||
remark := fmt.Sprintf("SUNO 任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
needRefund := job.Progress != 100
|
||||
fileURL := job.AudioURL
|
||||
|
||||
if needRefund {
|
||||
err := h.userService.IncreasePower(uint(userId), power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: md,
|
||||
Remark: remark,
|
||||
})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
tx.Commit()
|
||||
// remove file
|
||||
err := h.uploader.GetUploadHandler().Delete(fileURL)
|
||||
if err != nil {
|
||||
logger.Error("remove file failed: ", err)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package admin
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
@@ -17,11 +18,16 @@ import (
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -47,9 +53,220 @@ func (h *UserHandler) RegisterRoutes() {
|
||||
group.GET("loginLog", h.LoginLog)
|
||||
group.GET("genLoginLink", h.GenLoginLink)
|
||||
group.POST("resetPass", h.ResetPass)
|
||||
group.GET("import/template", h.ImportTemplate)
|
||||
group.POST("import", h.ImportUsers)
|
||||
}
|
||||
}
|
||||
|
||||
// ImportTemplate 下载用户导入模板
|
||||
func (h *UserHandler) ImportTemplate(c *gin.Context) {
|
||||
f := excelize.NewFile()
|
||||
sheetName := "Sheet1"
|
||||
// 表头
|
||||
headers := []string{"用户名", "密码", "手机", "邮箱", "剩余算力", "启用状态"}
|
||||
for i, title := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
_ = f.SetCellValue(sheetName, cell, title)
|
||||
}
|
||||
// 示例数据
|
||||
sample := []interface{}{"user001", "Passw0rd!", "13800000000", "user001@example.com", 100, 1}
|
||||
for i, v := range sample {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
|
||||
_ = f.SetCellValue(sheetName, cell, v)
|
||||
}
|
||||
|
||||
buf, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
logger.Error("failed to generate user import template: ", err)
|
||||
resp.ERROR(c, "生成模板失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
c.Header("Content-Disposition", "attachment; filename=\"user_import_template.xlsx\"")
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", buf.Bytes())
|
||||
}
|
||||
|
||||
// ImportUsers 批量导入用户
|
||||
func (h *UserHandler) ImportUsers(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
resp.ERROR(c, "文件上传失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||
if ext != ".xlsx" {
|
||||
resp.ERROR(c, "只支持 .xlsx 格式的 Excel 文件")
|
||||
return
|
||||
}
|
||||
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
resp.ERROR(c, "无法读取上传文件: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 读取到内存,避免多次读取问题
|
||||
var buf bytes.Buffer
|
||||
if _, err = buf.ReadFrom(src); err != nil {
|
||||
resp.ERROR(c, "读取文件内容失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
excel, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
resp.ERROR(c, "解析 Excel 失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = excel.Close()
|
||||
}()
|
||||
|
||||
rows, err := excel.GetRows("Sheet1")
|
||||
if err != nil {
|
||||
resp.ERROR(c, "读取工作表失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(rows) < 2 {
|
||||
resp.ERROR(c, "Excel 中没有可导入的数据")
|
||||
return
|
||||
}
|
||||
|
||||
type rowError struct {
|
||||
Row int `json:"row"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
var (
|
||||
successCount int
|
||||
failedCount int
|
||||
errorsList []rowError
|
||||
)
|
||||
|
||||
usernameSet := make(map[string]struct{})
|
||||
|
||||
// 从第二行开始读取
|
||||
for index, row := range rows[1:] {
|
||||
line := index + 2 // Excel 行号
|
||||
if len(row) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
get := func(i int) string {
|
||||
if i < len(row) {
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
username := get(0)
|
||||
password := get(1)
|
||||
mobile := get(2)
|
||||
email := get(3)
|
||||
powerStr := get(4)
|
||||
statusStr := get(5)
|
||||
|
||||
// 基础校验
|
||||
if username == "" {
|
||||
failedCount++
|
||||
errorsList = append(errorsList, rowError{Row: line, Error: "用户名不能为空"})
|
||||
continue
|
||||
}
|
||||
if _, ok := usernameSet[username]; ok {
|
||||
failedCount++
|
||||
errorsList = append(errorsList, rowError{Row: line, Error: "同一文件中用户名重复"})
|
||||
continue
|
||||
}
|
||||
usernameSet[username] = struct{}{}
|
||||
|
||||
if len(password) < 8 || len(password) > 16 {
|
||||
failedCount++
|
||||
errorsList = append(errorsList, rowError{Row: line, Error: "密码必须为 8-16 位"})
|
||||
continue
|
||||
}
|
||||
|
||||
if mobile != "" && len(mobile) != 11 {
|
||||
failedCount++
|
||||
errorsList = append(errorsList, rowError{Row: line, Error: "手机号必须为 11 位"})
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析算力
|
||||
power := 0
|
||||
if powerStr != "" {
|
||||
p, err := strconv.Atoi(powerStr)
|
||||
if err != nil {
|
||||
failedCount++
|
||||
errorsList = append(errorsList, rowError{Row: line, Error: "剩余算力必须为数字"})
|
||||
continue
|
||||
}
|
||||
if p < 0 {
|
||||
failedCount++
|
||||
errorsList = append(errorsList, rowError{Row: line, Error: "剩余算力不能为负数"})
|
||||
continue
|
||||
}
|
||||
power = p
|
||||
}
|
||||
|
||||
// 解析启用状态
|
||||
status := true
|
||||
if statusStr != "" {
|
||||
switch strings.TrimSpace(statusStr) {
|
||||
case "0", "否", "false", "停用":
|
||||
status = false
|
||||
case "1", "是", "true", "启用":
|
||||
status = true
|
||||
default:
|
||||
failedCount++
|
||||
errorsList = append(errorsList, rowError{Row: line, Error: "启用状态只支持 1/是 或 0/否"})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
var exist model.User
|
||||
if err = h.DB.Where("username = ?", username).First(&exist).Error; err == nil && exist.Id > 0 {
|
||||
failedCount++
|
||||
errorsList = append(errorsList, rowError{Row: line, Error: "用户名已存在"})
|
||||
continue
|
||||
}
|
||||
|
||||
salt := utils.RandString(8)
|
||||
u := model.User{
|
||||
Username: username,
|
||||
Password: utils.GenPassword(password, salt),
|
||||
Mobile: mobile,
|
||||
Email: email,
|
||||
Avatar: "/images/avatar/user.png",
|
||||
Salt: salt,
|
||||
Power: power,
|
||||
Status: status,
|
||||
ChatRoles: utils.JsonEncode([]string{}),
|
||||
ChatConfig: "{}",
|
||||
ChatModels: utils.JsonEncode([]int{}),
|
||||
ExpiredTime: 0, // 长期有效
|
||||
Vip: false,
|
||||
}
|
||||
u.Nickname = fmt.Sprintf("用户@%d", utils.RandomNumber(6))
|
||||
|
||||
if err = h.DB.Create(&u).Error; err != nil {
|
||||
failedCount++
|
||||
errorsList = append(errorsList, rowError{Row: line, Error: "写入数据库失败: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, gin.H{
|
||||
"success": successCount,
|
||||
"failed": failedCount,
|
||||
"errors": errorsList,
|
||||
})
|
||||
}
|
||||
|
||||
// List 用户列表
|
||||
func (h *UserHandler) List(c *gin.Context) {
|
||||
page := h.GetInt(c, "page", 1)
|
||||
@@ -96,17 +313,16 @@ func (h *UserHandler) List(c *gin.Context) {
|
||||
|
||||
func (h *UserHandler) Save(c *gin.Context) {
|
||||
var data struct {
|
||||
Id uint `json:"id"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Mobile string `json:"mobile"`
|
||||
Email string `json:"email"`
|
||||
ChatRoles []string `json:"chat_roles"`
|
||||
ChatModels []int `json:"chat_models"`
|
||||
ExpiredTime string `json:"expired_time"`
|
||||
Status bool `json:"status"`
|
||||
Vip bool `json:"vip"`
|
||||
Power int `json:"power"`
|
||||
Id uint `json:"id"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Mobile string `json:"mobile"`
|
||||
Email string `json:"email"`
|
||||
ChatModels []int `json:"chat_models"`
|
||||
ExpiredTime string `json:"expired_time"`
|
||||
Status bool `json:"status"`
|
||||
Vip bool `json:"vip"`
|
||||
Power int `json:"power"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
@@ -128,11 +344,10 @@ func (h *UserHandler) Save(c *gin.Context) {
|
||||
user.Status = data.Status
|
||||
user.Vip = data.Vip
|
||||
user.Power = data.Power
|
||||
user.ChatRoles = utils.JsonEncode(data.ChatRoles)
|
||||
user.ChatModels = utils.JsonEncode(data.ChatModels)
|
||||
user.ExpiredTime = utils.Str2stamp(data.ExpiredTime)
|
||||
|
||||
res = h.DB.Select("username", "mobile", "email", "status", "vip", "power", "chat_roles_json", "chat_models_json", "expired_time").Updates(&user)
|
||||
res = h.DB.Select("username", "mobile", "email", "status", "vip", "power", "chat_models_json", "expired_time").Updates(&user)
|
||||
|
||||
if res.Error != nil {
|
||||
logger.Error("error with update database:", res.Error)
|
||||
@@ -184,7 +399,6 @@ func (h *UserHandler) Save(c *gin.Context) {
|
||||
Salt: salt,
|
||||
Power: data.Power,
|
||||
Status: true,
|
||||
ChatRoles: utils.JsonEncode(data.ChatRoles),
|
||||
ChatConfig: "{}",
|
||||
ChatModels: utils.JsonEncode(data.ChatModels),
|
||||
ExpiredTime: utils.Str2stamp(data.ExpiredTime),
|
||||
@@ -278,10 +492,7 @@ func (h *UserHandler) Remove(c *gin.Context) {
|
||||
if err = tx.Where("user_id = ?", id).Delete(&model.MidJourneyJob{}).Error; err != nil {
|
||||
break
|
||||
}
|
||||
if err = tx.Where("user_id = ?", id).Delete(&model.SdJob{}).Error; err != nil {
|
||||
break
|
||||
}
|
||||
if err = tx.Where("user_id = ?", id).Delete(&model.DallJob{}).Error; err != nil {
|
||||
if err = tx.Where("user_id = ?", id).Delete(&model.ImageJob{}).Error; err != nil {
|
||||
break
|
||||
}
|
||||
if err = tx.Where("user_id = ?", id).Delete(&model.SunoJob{}).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package admin
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
"geekai/handler"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VideoHandler 管理后台视频生成处理器
|
||||
type VideoHandler struct {
|
||||
handler.BaseHandler
|
||||
userService *service.UserService
|
||||
uploader *oss.UploaderManager
|
||||
}
|
||||
|
||||
// NewVideoHandler 创建管理后台视频生成处理器
|
||||
func NewVideoHandler(app *core.AppServer, db *gorm.DB, userService *service.UserService, manager *oss.UploaderManager) *VideoHandler {
|
||||
return &VideoHandler{
|
||||
BaseHandler: handler.BaseHandler{App: app, DB: db},
|
||||
userService: userService,
|
||||
uploader: manager,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册视频生成管理后台路由
|
||||
func (h *VideoHandler) RegisterRoutes() {
|
||||
rg := h.App.Engine.Group("/api/admin/video/")
|
||||
rg.Use(middleware.AdminAuthMiddleware(h.App.Config.AdminSession.SecretKey, h.App.Redis))
|
||||
{
|
||||
rg.GET("config", h.GetConfig)
|
||||
rg.POST("config/update", h.UpdateConfig)
|
||||
rg.POST("list", h.Videos)
|
||||
rg.GET("remove", h.Remove)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig 获取视频生成配置
|
||||
func (h *VideoHandler) GetConfig(c *gin.Context) {
|
||||
var config model.Config
|
||||
err := h.DB.Where("name", types.ConfigKeyVideo).First(&config).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 返回空配置
|
||||
resp.SUCCESS(c, types.VideoConfig{
|
||||
ApiURL: "",
|
||||
ApiKey: "",
|
||||
VideoPowers: make(map[string]types.VideoModelPower),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, "获取配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var videoConfig types.VideoConfig
|
||||
err = utils.JsonDecode(config.Value, &videoConfig)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "解析配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, videoConfig)
|
||||
}
|
||||
|
||||
// UpdateConfig 更新视频生成配置
|
||||
func (h *VideoHandler) UpdateConfig(c *gin.Context) {
|
||||
var req types.VideoConfig
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.ERROR(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.ApiURL == "" {
|
||||
resp.ERROR(c, "API地址不能为空")
|
||||
return
|
||||
}
|
||||
if req.ApiKey == "" {
|
||||
resp.ERROR(c, "API密钥不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证算力配置
|
||||
if len(req.VideoPowers) == 0 {
|
||||
resp.ERROR(c, "请至少配置一个模型的算力")
|
||||
return
|
||||
}
|
||||
|
||||
// 新的价格配置方式直接使用 power_config 中的 key(如 "fixed"、"5_720P" 等)
|
||||
// 不再区分固定收费和按秒收费,所有价格配置都在 power_config 中
|
||||
for key, modelPower := range req.VideoPowers {
|
||||
// 验证 provider
|
||||
if modelPower.Provider == "" {
|
||||
resp.ERROR(c, fmt.Sprintf("模型 %s 的 provider 不能为空", key))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 model
|
||||
if modelPower.Model == "" {
|
||||
resp.ERROR(c, fmt.Sprintf("模型 %s 的 model 不能为空", key))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 power_config
|
||||
if len(modelPower.PowerConfig) == 0 {
|
||||
resp.ERROR(c, fmt.Sprintf("模型 %s 的 power_config 不能为空", key))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 power_config 中的值必须大于0
|
||||
for configKey, configValue := range modelPower.PowerConfig {
|
||||
if configValue <= 0 {
|
||||
resp.ERROR(c, fmt.Sprintf("模型 %s 的 power_config.%s 必须大于0", key, configKey))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
tx := h.DB.Begin()
|
||||
value := utils.JsonEncode(&req)
|
||||
var exist model.Config
|
||||
tx.Where("name", types.ConfigKeyVideo).First(&exist)
|
||||
|
||||
if exist.Id > 0 {
|
||||
exist.Value = value
|
||||
err := tx.Updates(&exist).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, "更新配置失败: "+err.Error())
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
exist.Name = types.ConfigKeyVideo
|
||||
exist.Value = value
|
||||
err := tx.Create(&exist).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, "创建配置失败: "+err.Error())
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
resp.SUCCESS(c, gin.H{"message": "配置更新成功"})
|
||||
}
|
||||
|
||||
type videoQuery struct {
|
||||
Type string `json:"type"` // 任务类型 luma, keling
|
||||
Status string `json:"status"` // 任务状态 pending, in_progress, downloading, success, failed
|
||||
Prompt string `json:"prompt"`
|
||||
CreatedAt []string `json:"created_at"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// Videos 视频任务列表
|
||||
func (h *VideoHandler) Videos(c *gin.Context) {
|
||||
var data videoQuery
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
session := h.DB.Session(&gorm.Session{})
|
||||
if data.Type != "" {
|
||||
session = session.Where("type", data.Type)
|
||||
}
|
||||
if data.Status != "" {
|
||||
session = session.Where("status", data.Status)
|
||||
}
|
||||
if data.Prompt != "" {
|
||||
session = session.Where("prompt LIKE ?", "%"+data.Prompt+"%")
|
||||
}
|
||||
if len(data.CreatedAt) == 2 {
|
||||
session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1])
|
||||
}
|
||||
var total int64
|
||||
session.Model(&model.VideoJob{}).Count(&total)
|
||||
var list []model.VideoJob
|
||||
var items = make([]vo.VideoJob, 0)
|
||||
offset := (data.Page - 1) * data.PageSize
|
||||
err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error
|
||||
if err == nil {
|
||||
// 填充数据
|
||||
for _, item := range list {
|
||||
var job vo.VideoJob
|
||||
err = utils.CopyObject(item, &job)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
job.CreatedAt = item.CreatedAt.Unix()
|
||||
items = append(items, job)
|
||||
}
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items))
|
||||
}
|
||||
|
||||
func (h *VideoHandler) Remove(c *gin.Context) {
|
||||
id := h.GetInt(c, "id", 0)
|
||||
tab := c.Query("tab")
|
||||
|
||||
tx := h.DB.Begin()
|
||||
var md, remark, fileURL string
|
||||
var power, userId int
|
||||
var needRefund bool
|
||||
|
||||
switch tab {
|
||||
case "luma", "keling":
|
||||
var job model.VideoJob
|
||||
if res := h.DB.Where("id", id).First(&job); res.Error != nil {
|
||||
resp.ERROR(c, "记录不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
tx.Delete(&job)
|
||||
md = job.Type
|
||||
power = job.Power
|
||||
userId = int(job.UserId)
|
||||
remark = fmt.Sprintf("视频任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
needRefund = job.Status != types.VideoStatusSuccess
|
||||
fileURL = job.VideoURL
|
||||
default:
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
if needRefund {
|
||||
err := h.userService.IncreasePower(uint(userId), power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: md,
|
||||
Remark: remark,
|
||||
})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
tx.Commit()
|
||||
// remove file
|
||||
err := h.uploader.GetUploadHandler().Delete(fileURL)
|
||||
if err != nil {
|
||||
logger.Error("remove file failed: ", err)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"strings"
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
type BaseHandler struct {
|
||||
App *core.AppServer
|
||||
|
||||
+166
-31
@@ -37,7 +37,11 @@ func (h *ChatAppHandler) RegisterRoutes() {
|
||||
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
|
||||
{
|
||||
group.GET("list/user", h.ListByUser)
|
||||
group.POST("create", h.Create)
|
||||
group.POST("copy", h.Copy)
|
||||
group.POST("update", h.UpdateApp)
|
||||
group.POST("workspace", h.UpdateWorkArea)
|
||||
group.POST("remove", h.Remove)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +49,7 @@ func (h *ChatAppHandler) RegisterRoutes() {
|
||||
func (h *ChatAppHandler) List(c *gin.Context) {
|
||||
tid := h.GetInt(c, "tid", 0)
|
||||
var roles []model.ChatApp
|
||||
session := h.DB.Where("enable", true)
|
||||
session := h.DB.Where("enable = ? AND user_id = 0", true)
|
||||
if tid > 0 {
|
||||
session = session.Where("tid", tid)
|
||||
}
|
||||
@@ -61,6 +65,9 @@ func (h *ChatAppHandler) List(c *gin.Context) {
|
||||
err := utils.CopyObject(r, &v)
|
||||
if err == nil {
|
||||
v.Id = r.Id
|
||||
if r.UserId == 0 {
|
||||
v.SystemPrompt = ""
|
||||
}
|
||||
roleVos = append(roleVos, v)
|
||||
}
|
||||
}
|
||||
@@ -72,23 +79,11 @@ func (h *ChatAppHandler) ListByUser(c *gin.Context) {
|
||||
id := h.GetInt(c, "id", 0)
|
||||
userId := h.GetLoginUserId(c)
|
||||
var roles []model.ChatApp
|
||||
session := h.DB.Where("enable", true)
|
||||
// 如果用户没登录,则获取所有角色
|
||||
session := h.DB.Where("enable = ?", true)
|
||||
if userId > 0 {
|
||||
var user model.User
|
||||
h.DB.First(&user, userId)
|
||||
var roleKeys []string
|
||||
if user.ChatRoles != "" {
|
||||
err := utils.JsonDecode(user.ChatRoles, &roleKeys)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "角色解析失败!")
|
||||
return
|
||||
}
|
||||
}
|
||||
// 保证用户至少有一个角色可用
|
||||
if len(roleKeys) > 0 {
|
||||
session = session.Where("marker IN ?", roleKeys)
|
||||
}
|
||||
session = session.Where("(user_id = 0 OR user_id = ?)", userId)
|
||||
} else {
|
||||
session = session.Where("user_id = 0")
|
||||
}
|
||||
|
||||
if id > 0 {
|
||||
@@ -106,33 +101,173 @@ func (h *ChatAppHandler) ListByUser(c *gin.Context) {
|
||||
err := utils.CopyObject(r, &v)
|
||||
if err == nil {
|
||||
v.Id = r.Id
|
||||
if r.UserId == 0 {
|
||||
v.SystemPrompt = ""
|
||||
}
|
||||
roleVos = append(roleVos, v)
|
||||
}
|
||||
}
|
||||
resp.SUCCESS(c, roleVos)
|
||||
}
|
||||
|
||||
// UpdateApp 更新用户聊天应用
|
||||
func (h *ChatAppHandler) UpdateApp(c *gin.Context) {
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
// Create 用户创建智能体
|
||||
func (h *ChatAppHandler) Create(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
if userId == 0 {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
if err = c.ShouldBindJSON(&data); err != nil {
|
||||
var data vo.ChatApp
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.DB.Model(&model.User{}).Where("id = ?", user.Id).UpdateColumn("chat_roles_json", utils.JsonEncode(data.Keys)).Error
|
||||
if err != nil {
|
||||
role := model.ChatApp{
|
||||
Name: data.Name,
|
||||
Tid: data.Tid,
|
||||
UserId: userId,
|
||||
SystemPrompt: data.SystemPrompt,
|
||||
HelloMsg: data.HelloMsg,
|
||||
Icon: data.Icon,
|
||||
Enable: true,
|
||||
SortNum: int(data.SortNum),
|
||||
ModelId: data.ModelId,
|
||||
}
|
||||
if role.Icon == "" {
|
||||
role.Icon = "/images/avatar/gpt.png"
|
||||
}
|
||||
if err := h.DB.Create(&role).Error; err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c)
|
||||
data.Id = role.Id
|
||||
data.UserId = role.UserId
|
||||
resp.SUCCESS(c, data)
|
||||
}
|
||||
|
||||
// Copy 用户复制智能体(复制为当前用户名下)
|
||||
func (h *ChatAppHandler) Copy(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
if userId == 0 {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
SourceId uint `json:"source_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.SourceId == 0 {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
var src model.ChatApp
|
||||
if err := h.DB.First(&src, body.SourceId).Error; err != nil {
|
||||
resp.ERROR(c, "智能体不存在")
|
||||
return
|
||||
}
|
||||
role := model.ChatApp{
|
||||
Name: src.Name,
|
||||
Tid: src.Tid,
|
||||
UserId: userId,
|
||||
SystemPrompt: src.SystemPrompt,
|
||||
HelloMsg: src.HelloMsg,
|
||||
Icon: src.Icon,
|
||||
Enable: true,
|
||||
SortNum: src.SortNum,
|
||||
ModelId: src.ModelId,
|
||||
}
|
||||
if err := h.DB.Create(&role).Error; err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c, gin.H{"id": role.Id})
|
||||
}
|
||||
|
||||
// UpdateApp 更新用户聊天应用(仅允许更新自己创建的)
|
||||
func (h *ChatAppHandler) UpdateApp(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
if userId == 0 {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
var data vo.ChatApp
|
||||
if err := c.ShouldBindJSON(&data); err != nil || data.Id == 0 {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
var role model.ChatApp
|
||||
if err := h.DB.First(&role, data.Id).Error; err != nil {
|
||||
resp.ERROR(c, "智能体不存在")
|
||||
return
|
||||
}
|
||||
if role.UserId != userId {
|
||||
resp.ERROR(c, "无权限修改该智能体")
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"name": data.Name,
|
||||
"hello_msg": data.HelloMsg,
|
||||
"icon": data.Icon,
|
||||
"model_id": data.ModelId,
|
||||
"system_prompt": data.SystemPrompt,
|
||||
}
|
||||
if err := h.DB.Model(&role).Updates(updates).Error; err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c, nil)
|
||||
}
|
||||
|
||||
// UpdateWorkArea 更新用户工作区应用列表(存为应用 id 数组)
|
||||
func (h *ChatAppHandler) UpdateWorkArea(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
if userId == 0 {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Ids []uint `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
if err := h.DB.Model(&model.User{}).Where("id = ?", userId).Update("chat_roles_json", utils.JsonEncode(body.Ids)).Error; err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c, nil)
|
||||
}
|
||||
|
||||
// Remove 删除用户智能体(仅允许删除自己创建的)
|
||||
func (h *ChatAppHandler) Remove(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
if userId == 0 {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Id uint `json:"id"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if body.Id == 0 {
|
||||
body.Id = uint(h.GetInt(c, "id", 0))
|
||||
}
|
||||
if body.Id == 0 {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
var role model.ChatApp
|
||||
if err := h.DB.First(&role, body.Id).Error; err != nil {
|
||||
resp.ERROR(c, "智能体不存在")
|
||||
return
|
||||
}
|
||||
if role.UserId != userId {
|
||||
resp.ERROR(c, "无权限删除该智能体")
|
||||
return
|
||||
}
|
||||
if err := h.DB.Delete(&role).Error; err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c, nil)
|
||||
}
|
||||
|
||||
@@ -95,20 +95,21 @@ func NewChatHandler(app *core.AppServer,
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *ChatHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/chat/")
|
||||
group.GET("detail", h.Detail)
|
||||
group.GET("history", h.History)
|
||||
// 其他接口需要用户授权
|
||||
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
|
||||
{
|
||||
group.Any("message", h.Chat)
|
||||
group.GET("list", h.List)
|
||||
group.GET("detail", h.Detail)
|
||||
group.POST("update", h.Update)
|
||||
group.GET("remove", h.Remove)
|
||||
group.GET("history", h.History)
|
||||
group.GET("clear", h.Clear)
|
||||
group.POST("tokens", h.Tokens)
|
||||
group.GET("stop", h.StopGenerate)
|
||||
group.POST("tts", h.TextToSpeech)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Chat 处理聊天请求
|
||||
@@ -272,7 +273,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, input ChatInput, c *gin.C
|
||||
chatCtx := make([]any, 0)
|
||||
messages := make([]any, 0)
|
||||
if h.App.SysConfig.Base.EnableContext {
|
||||
_ = utils.JsonDecode(input.ChatRole.Context, &messages)
|
||||
_ = utils.JsonDecode(input.ChatRole.SystemPrompt, &messages)
|
||||
if h.App.SysConfig.Base.ContextDeep > 0 {
|
||||
var historyMessages []model.ChatMessage
|
||||
dbSession := h.DB.Session(&gorm.Session{}).Where("chat_id", input.ChatId)
|
||||
@@ -668,7 +669,10 @@ func (h *ChatHandler) saveChatHistory(
|
||||
files := make([]vo.File, 0)
|
||||
if strings.HasPrefix(req.Model, "sora") {
|
||||
video, err := h.soraService.DownloadVideoURL(message.Content)
|
||||
if err == nil {
|
||||
if err != nil {
|
||||
logger.Error("failed to download video: ", err)
|
||||
pushMessage(c, ChatEventError, "视频下载失败:"+err.Error())
|
||||
} else {
|
||||
files = append(files, *video)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ package handler
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"geekai/core"
|
||||
"geekai/core/types"
|
||||
"geekai/service/oss"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
@@ -19,10 +22,16 @@ import (
|
||||
|
||||
type ConfigHandler struct {
|
||||
BaseHandler
|
||||
uploaderManager *oss.UploaderManager
|
||||
sysConfig *types.SystemConfig
|
||||
}
|
||||
|
||||
func NewConfigHandler(app *core.AppServer, db *gorm.DB) *ConfigHandler {
|
||||
return &ConfigHandler{BaseHandler: BaseHandler{App: app, DB: db}}
|
||||
func NewConfigHandler(app *core.AppServer, db *gorm.DB, uploaderManager *oss.UploaderManager, sysConfig *types.SystemConfig) *ConfigHandler {
|
||||
return &ConfigHandler{
|
||||
BaseHandler: BaseHandler{App: app, DB: db},
|
||||
uploaderManager: uploaderManager,
|
||||
sysConfig: sysConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
@@ -31,24 +40,44 @@ func (h *ConfigHandler) RegisterRoutes() {
|
||||
|
||||
// 无需授权的接口
|
||||
group.GET("get", h.Get)
|
||||
group.GET("oss/thumb", h.GetOssThumbTemplate)
|
||||
}
|
||||
|
||||
// Get 获取指定的系统配置
|
||||
func (h *ConfigHandler) Get(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
var config model.Config
|
||||
res := h.DB.Where("name", key).First(&config)
|
||||
if res.Error != nil {
|
||||
resp.ERROR(c, res.Error.Error())
|
||||
err := h.DB.Where("name", key).First(&config).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.SUCCESS(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var value map[string]any
|
||||
err := utils.JsonDecode(config.Value, &value)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var value map[string]any
|
||||
err = utils.JsonDecode(config.Value, &value)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if key == types.ConfigKeyWxGzh {
|
||||
delete(value, "secret")
|
||||
delete(value, "token")
|
||||
delete(value, "encoding_aes_key")
|
||||
}
|
||||
resp.SUCCESS(c, value)
|
||||
}
|
||||
|
||||
// GetOssThumbTemplate 获取当前存储引擎的缩略图模板
|
||||
func (h *ConfigHandler) GetOssThumbTemplate(c *gin.Context) {
|
||||
template := h.uploaderManager.GetThumbTemplate()
|
||||
resp.SUCCESS(c, gin.H{
|
||||
"template": template,
|
||||
"active": h.sysConfig.OSS.Active,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"geekai/core"
|
||||
"geekai/core/types"
|
||||
"geekai/service"
|
||||
"geekai/service/dalle"
|
||||
"geekai/service/image"
|
||||
"geekai/service/oss"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
type FunctionHandler struct {
|
||||
BaseHandler
|
||||
uploadManager *oss.UploaderManager
|
||||
dallService *dalle.Service
|
||||
imageService *image.Service
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func NewFunctionHandler(
|
||||
db *gorm.DB,
|
||||
config *types.AppConfig,
|
||||
manager *oss.UploaderManager,
|
||||
dallService *dalle.Service,
|
||||
imageService *image.Service,
|
||||
userService *service.UserService) *FunctionHandler {
|
||||
return &FunctionHandler{
|
||||
BaseHandler: BaseHandler{
|
||||
@@ -48,7 +48,7 @@ func NewFunctionHandler(
|
||||
DB: db,
|
||||
},
|
||||
uploadManager: manager,
|
||||
dallService: dallService,
|
||||
imageService: imageService,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func (h *FunctionHandler) RegisterRoutes() {
|
||||
// 需要用户授权的接口
|
||||
group.POST("weibo", h.WeiBo)
|
||||
group.POST("zaobao", h.ZaoBao)
|
||||
group.POST("dalle3", h.Dall3)
|
||||
group.POST("image3", h.Image3)
|
||||
}
|
||||
|
||||
type resVo struct {
|
||||
@@ -176,8 +176,8 @@ func (h *FunctionHandler) ZaoBao(c *gin.Context) {
|
||||
resp.SUCCESS(c, strings.Join(builder, "\n\n"))
|
||||
}
|
||||
|
||||
// Dall3 DallE3 AI 绘图
|
||||
func (h *FunctionHandler) Dall3(c *gin.Context) {
|
||||
// Image3 AI 图像生成
|
||||
func (h *FunctionHandler) Image3(c *gin.Context) {
|
||||
if err := h.checkAuth(c); err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
@@ -209,9 +209,9 @@ func (h *FunctionHandler) Dall3(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// create dall task
|
||||
// create image task
|
||||
prompt := utils.InterfaceToString(params["prompt"])
|
||||
task := types.DallTask{
|
||||
task := types.ImageTask{
|
||||
UserId: user.Id,
|
||||
Prompt: prompt,
|
||||
ModelId: chatModel.Id,
|
||||
@@ -220,11 +220,11 @@ func (h *FunctionHandler) Dall3(c *gin.Context) {
|
||||
TranslateModelId: h.App.SysConfig.Base.AssistantModelId,
|
||||
Power: chatModel.Power,
|
||||
}
|
||||
job := model.DallJob{
|
||||
UserId: user.Id,
|
||||
Prompt: prompt,
|
||||
Power: chatModel.Power,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
job := model.ImageJob{
|
||||
UserId: user.Id,
|
||||
Prompt: prompt,
|
||||
Power: chatModel.Power,
|
||||
Params: utils.JsonEncode(task),
|
||||
}
|
||||
err := h.DB.Create(&job).Error
|
||||
if err != nil {
|
||||
@@ -233,7 +233,7 @@ func (h *FunctionHandler) Dall3(c *gin.Context) {
|
||||
}
|
||||
|
||||
task.Id = job.Id
|
||||
content, err := h.dallService.Image(task, true)
|
||||
content, err := h.imageService.Image(task, true)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "任务执行失败:"+err.Error())
|
||||
return
|
||||
|
||||
@@ -3,7 +3,7 @@ package handler
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * that can be found in LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
"geekai/service"
|
||||
"geekai/service/dalle"
|
||||
"geekai/service/image"
|
||||
"geekai/service/moderation"
|
||||
"geekai/service/oss"
|
||||
"geekai/store/model"
|
||||
@@ -25,17 +25,17 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DallJobHandler struct {
|
||||
type ImageJobHandler struct {
|
||||
BaseHandler
|
||||
dallService *dalle.Service
|
||||
imageService *image.Service
|
||||
uploader *oss.UploaderManager
|
||||
userService *service.UserService
|
||||
moderationManager *moderation.ServiceManager
|
||||
}
|
||||
|
||||
func NewDallJobHandler(app *core.AppServer, db *gorm.DB, service *dalle.Service, manager *oss.UploaderManager, userService *service.UserService, moderationManager *moderation.ServiceManager) *DallJobHandler {
|
||||
return &DallJobHandler{
|
||||
dallService: service,
|
||||
func NewImageJobHandler(app *core.AppServer, db *gorm.DB, service *image.Service, manager *oss.UploaderManager, userService *service.UserService, moderationManager *moderation.ServiceManager) *ImageJobHandler {
|
||||
return &ImageJobHandler{
|
||||
imageService: service,
|
||||
uploader: manager,
|
||||
userService: userService,
|
||||
moderationManager: moderationManager,
|
||||
@@ -47,8 +47,8 @@ func NewDallJobHandler(app *core.AppServer, db *gorm.DB, service *dalle.Service,
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *DallJobHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/dall/")
|
||||
func (h *ImageJobHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/image/")
|
||||
|
||||
// 公开接口,不需要授权
|
||||
group.GET("imgWall", h.ImgWall)
|
||||
@@ -65,8 +65,8 @@ func (h *DallJobHandler) RegisterRoutes() {
|
||||
}
|
||||
|
||||
// Image 创建一个绘画任务
|
||||
func (h *DallJobHandler) Image(c *gin.Context) {
|
||||
var data types.DallTask
|
||||
func (h *ImageJobHandler) Image(c *gin.Context) {
|
||||
var data types.ImageTask
|
||||
if err := c.ShouldBindJSON(&data); err != nil || data.Prompt == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
@@ -82,7 +82,7 @@ func (h *DallJobHandler) Image(c *gin.Context) {
|
||||
// 记录违规内容
|
||||
moderation := model.Moderation{
|
||||
UserId: h.GetLoginUserId(c),
|
||||
Source: types.ModerationSourceDalle,
|
||||
Source: types.ModerationSourceImage,
|
||||
Input: data.Prompt,
|
||||
Result: utils.JsonEncode(moderationResult),
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func (h *DallJobHandler) Image(c *gin.Context) {
|
||||
|
||||
idValue, _ := c.Get(types.LoginUserID)
|
||||
userId := utils.IntValue(utils.InterfaceToString(idValue), 0)
|
||||
task := types.DallTask{
|
||||
task := types.ImageTask{
|
||||
UserId: uint(userId),
|
||||
ModelId: chatModel.Id,
|
||||
ModelName: chatModel.Name,
|
||||
@@ -126,11 +126,11 @@ func (h *DallJobHandler) Image(c *gin.Context) {
|
||||
TranslateModelId: h.App.SysConfig.Base.AssistantModelId,
|
||||
Power: chatModel.Power,
|
||||
}
|
||||
job := model.DallJob{
|
||||
UserId: uint(userId),
|
||||
Prompt: data.Prompt,
|
||||
Power: chatModel.Power,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
job := model.ImageJob{
|
||||
UserId: uint(userId),
|
||||
Prompt: data.Prompt,
|
||||
Power: chatModel.Power,
|
||||
Params: utils.JsonEncode(task),
|
||||
}
|
||||
res := h.DB.Create(&job)
|
||||
if res.Error != nil {
|
||||
@@ -139,7 +139,7 @@ func (h *DallJobHandler) Image(c *gin.Context) {
|
||||
}
|
||||
|
||||
task.Id = job.Id
|
||||
h.dallService.PushTask(task)
|
||||
h.imageService.PushTask(task)
|
||||
|
||||
// 扣减算力
|
||||
err = h.userService.DecreasePower(user.Id, chatModel.Power, model.PowerLog{
|
||||
@@ -155,7 +155,7 @@ func (h *DallJobHandler) Image(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ImgWall 照片墙
|
||||
func (h *DallJobHandler) ImgWall(c *gin.Context) {
|
||||
func (h *ImageJobHandler) ImgWall(c *gin.Context) {
|
||||
page := h.GetInt(c, "page", 0)
|
||||
pageSize := h.GetInt(c, "page_size", 0)
|
||||
err, jobs := h.getData(true, 0, page, pageSize, true)
|
||||
@@ -167,8 +167,8 @@ func (h *DallJobHandler) ImgWall(c *gin.Context) {
|
||||
resp.SUCCESS(c, jobs)
|
||||
}
|
||||
|
||||
// JobList 获取 SD 任务列表
|
||||
func (h *DallJobHandler) JobList(c *gin.Context) {
|
||||
// JobList 获取 Image 任务列表
|
||||
func (h *ImageJobHandler) JobList(c *gin.Context) {
|
||||
finish := h.GetBool(c, "finish")
|
||||
userId := h.GetLoginUserId(c)
|
||||
page := h.GetInt(c, "page", 0)
|
||||
@@ -185,7 +185,7 @@ func (h *DallJobHandler) JobList(c *gin.Context) {
|
||||
}
|
||||
|
||||
// JobList 获取任务列表
|
||||
func (h *DallJobHandler) getData(finish bool, userId uint, page int, pageSize int, publish bool) (error, vo.Page) {
|
||||
func (h *ImageJobHandler) getData(finish bool, userId uint, page int, pageSize int, publish bool) (error, vo.Page) {
|
||||
|
||||
session := h.DB.Session(&gorm.Session{})
|
||||
if finish {
|
||||
@@ -205,21 +205,22 @@ func (h *DallJobHandler) getData(finish bool, userId uint, page int, pageSize in
|
||||
}
|
||||
// 统计总数
|
||||
var total int64
|
||||
session.Model(&model.DallJob{}).Count(&total)
|
||||
session.Model(&model.ImageJob{}).Count(&total)
|
||||
|
||||
var items []model.DallJob
|
||||
var items []model.ImageJob
|
||||
res := session.Find(&items)
|
||||
if res.Error != nil {
|
||||
return res.Error, vo.Page{}
|
||||
}
|
||||
|
||||
var jobs = make([]vo.DallJob, 0)
|
||||
var jobs = make([]vo.ImageJob, 0)
|
||||
for _, item := range items {
|
||||
var job vo.DallJob
|
||||
var job vo.ImageJob
|
||||
err := utils.CopyObject(item, &job)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
job.CreatedAt = item.CreatedAt.Unix()
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
@@ -227,10 +228,10 @@ func (h *DallJobHandler) getData(finish bool, userId uint, page int, pageSize in
|
||||
}
|
||||
|
||||
// Remove remove task image
|
||||
func (h *DallJobHandler) Remove(c *gin.Context) {
|
||||
func (h *ImageJobHandler) Remove(c *gin.Context) {
|
||||
id := h.GetInt(c, "id", 0)
|
||||
userId := h.GetLoginUserId(c)
|
||||
var job model.DallJob
|
||||
var job model.ImageJob
|
||||
if res := h.DB.Where("id = ? AND user_id = ?", id, userId).First(&job); res.Error != nil {
|
||||
resp.ERROR(c, "记录不存在")
|
||||
return
|
||||
@@ -253,12 +254,12 @@ func (h *DallJobHandler) Remove(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Publish 发布/取消发布图片到画廊显示
|
||||
func (h *DallJobHandler) Publish(c *gin.Context) {
|
||||
func (h *ImageJobHandler) Publish(c *gin.Context) {
|
||||
id := h.GetInt(c, "id", 0)
|
||||
userId := h.GetLoginUserId(c)
|
||||
action := h.GetBool(c, "action") // 发布动作,true => 发布,false => 取消分享
|
||||
|
||||
err := h.DB.Model(&model.DallJob{Id: uint(id), UserId: userId}).UpdateColumn("publish", action).Error
|
||||
err := h.DB.Model(&model.ImageJob{Id: uint(id), UserId: userId}).UpdateColumn("publish", action).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
@@ -267,7 +268,7 @@ func (h *DallJobHandler) Publish(c *gin.Context) {
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
|
||||
func (h *DallJobHandler) GetModels(c *gin.Context) {
|
||||
func (h *ImageJobHandler) GetModels(c *gin.Context) {
|
||||
var models []model.ChatModel
|
||||
err := h.DB.Where("type", "img").Where("enabled", true).Find(&models).Error
|
||||
if err != nil {
|
||||
+116
-6
@@ -63,6 +63,7 @@ func (h *MidJourneyHandler) RegisterRoutes() {
|
||||
group.POST("image", h.Image)
|
||||
group.POST("upscale", h.Upscale)
|
||||
group.POST("variation", h.Variation)
|
||||
group.POST("modal", h.Modal)
|
||||
group.GET("jobs", h.JobList)
|
||||
group.GET("remove", h.Remove)
|
||||
group.GET("publish", h.Publish)
|
||||
@@ -82,7 +83,31 @@ func (h *MidJourneyHandler) preCheck(c *gin.Context) bool {
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// preCheckPower 检查用户算力是否 >= required,不足时写 ERROR 并返回 false
|
||||
func (h *MidJourneyHandler) preCheckPower(c *gin.Context, required int) bool {
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return false
|
||||
}
|
||||
if required <= 0 {
|
||||
required = h.App.SysConfig.Base.MjActionPower
|
||||
}
|
||||
if user.Power < required {
|
||||
resp.ERROR(c, "当前用户剩余算力不足以完成本次操作!")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mjActionPower 取分项算力,若未配置则回退 MjActionPower
|
||||
func mjActionPower(base int, fallback int) int {
|
||||
if base > 0 {
|
||||
return base
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Image 创建一个绘画任务
|
||||
@@ -109,7 +134,14 @@ func (h *MidJourneyHandler) Image(c *gin.Context) {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
if !h.preCheck(c) {
|
||||
// 按任务类型计算所需算力
|
||||
power := h.App.SysConfig.Base.MjPower
|
||||
if data.TaskType == types.TaskBlend.String() {
|
||||
power = mjActionPower(h.App.SysConfig.Base.MjBlendPower, h.App.SysConfig.Base.MjActionPower)
|
||||
} else if data.TaskType == types.TaskSwapFace.String() {
|
||||
power = mjActionPower(h.App.SysConfig.Base.MjSwapFacePower, h.App.SysConfig.Base.MjActionPower)
|
||||
}
|
||||
if !h.preCheckPower(c, power) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -215,7 +247,7 @@ func (h *MidJourneyHandler) Image(c *gin.Context) {
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Progress: 0,
|
||||
Prompt: fmt.Sprintf("%s %s", data.Prompt, params),
|
||||
Power: h.App.SysConfig.Base.MjPower,
|
||||
Power: power,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
opt := "绘图"
|
||||
@@ -264,7 +296,8 @@ func (h *MidJourneyHandler) Upscale(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.preCheck(c) {
|
||||
power := mjActionPower(h.App.SysConfig.Base.MjUpscalePower, h.App.SysConfig.Base.MjActionPower)
|
||||
if !h.preCheckPower(c, power) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -286,7 +319,7 @@ func (h *MidJourneyHandler) Upscale(c *gin.Context) {
|
||||
TaskId: taskId,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Progress: 0,
|
||||
Power: h.App.SysConfig.Base.MjActionPower,
|
||||
Power: power,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if res := h.DB.Create(&job); res.Error != nil || res.RowsAffected == 0 {
|
||||
@@ -319,7 +352,8 @@ func (h *MidJourneyHandler) Variation(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.preCheck(c) {
|
||||
power := mjActionPower(h.App.SysConfig.Base.MjUpscalePower, h.App.SysConfig.Base.MjActionPower)
|
||||
if !h.preCheckPower(c, power) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -342,7 +376,7 @@ func (h *MidJourneyHandler) Variation(c *gin.Context) {
|
||||
TaskId: taskId,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Progress: 0,
|
||||
Power: h.App.SysConfig.Base.MjActionPower,
|
||||
Power: power,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if res := h.DB.Create(&job); res.Error != nil || res.RowsAffected == 0 {
|
||||
@@ -366,6 +400,81 @@ func (h *MidJourneyHandler) Variation(c *gin.Context) {
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
|
||||
// modalReq 局部重绘请求参数
|
||||
type modalReq struct {
|
||||
TaskId string `json:"task_id"` // 原图任务 ID(message_id)
|
||||
ChannelId string `json:"channel_id"` // 渠道 ID
|
||||
Prompt string `json:"prompt"` // 提示词
|
||||
MaskBase64 string `json:"mask_base64,omitempty"` // 蒙版 base64,可选
|
||||
}
|
||||
|
||||
// Modal 提交局部重绘(inpaint)
|
||||
func (h *MidJourneyHandler) Modal(c *gin.Context) {
|
||||
var data modalReq
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
if data.TaskId == "" || data.ChannelId == "" {
|
||||
resp.ERROR(c, "task_id 与 channel_id 必填")
|
||||
return
|
||||
}
|
||||
if data.Prompt == "" {
|
||||
resp.ERROR(c, "请填写局部重绘提示词")
|
||||
return
|
||||
}
|
||||
|
||||
power := mjActionPower(h.App.SysConfig.Base.MjModalPower, h.App.SysConfig.Base.MjActionPower)
|
||||
if !h.preCheckPower(c, power) {
|
||||
return
|
||||
}
|
||||
|
||||
idValue, _ := c.Get(types.LoginUserID)
|
||||
userId := utils.IntValue(utils.InterfaceToString(idValue), 0)
|
||||
taskId, _ := h.snowflake.Next(true)
|
||||
// 原图 message_id 必须传入 API,同时写入 TaskId/MessageId 避免序列化 omitempty 丢失
|
||||
task := types.MjTask{
|
||||
Type: types.TaskModal,
|
||||
UserId: userId,
|
||||
ChannelId: data.ChannelId,
|
||||
TaskId: data.TaskId,
|
||||
MessageId: data.TaskId,
|
||||
Prompt: data.Prompt,
|
||||
MaskBase64: data.MaskBase64,
|
||||
Mode: h.App.SysConfig.Base.MjMode,
|
||||
}
|
||||
job := model.MidJourneyJob{
|
||||
Type: types.TaskModal.String(),
|
||||
ChannelId: data.ChannelId,
|
||||
UserId: uint(userId),
|
||||
TaskId: taskId,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Progress: 0,
|
||||
Prompt: data.Prompt,
|
||||
Power: power,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if res := h.DB.Create(&job); res.Error != nil || res.RowsAffected == 0 {
|
||||
resp.ERROR(c, "添加任务失败:"+res.Error.Error())
|
||||
return
|
||||
}
|
||||
|
||||
task.Id = job.Id
|
||||
h.mjService.PushTask(task)
|
||||
|
||||
err := h.userService.DecreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerConsume,
|
||||
Model: "mid-journey",
|
||||
Remark: fmt.Sprintf("局部重绘操作,任务ID:%s", job.TaskId),
|
||||
})
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
|
||||
// ImgWall 照片墙
|
||||
func (h *MidJourneyHandler) ImgWall(c *gin.Context) {
|
||||
page := h.GetInt(c, "page", 0)
|
||||
@@ -432,6 +541,7 @@ func (h *MidJourneyHandler) getData(finish bool, userId uint, page int, pageSize
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
job.CreatedAt = item.CreatedAt.Unix()
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
return nil, vo.NewPage(total, page, pageSize, jobs)
|
||||
|
||||
@@ -216,7 +216,7 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
||||
data.Domain = h.config.WxPay.Domain
|
||||
}
|
||||
notifyURL = fmt.Sprintf("%s/api/payment/notify/wxpay", data.Domain)
|
||||
payURL, err = h.wxpayService.Pay(payment.PayRequest{
|
||||
params := payment.PayRequest{
|
||||
OutTradeNo: orderNo,
|
||||
TotalFee: fmt.Sprintf("%d", int(amount*100)),
|
||||
Subject: product.Name,
|
||||
@@ -224,7 +224,11 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
||||
ClientIP: c.ClientIP(),
|
||||
Device: data.Device,
|
||||
PayWay: payment.PayWayWX,
|
||||
})
|
||||
}
|
||||
if data.Device == "mobile" {
|
||||
params.OpenID = user.OpenId
|
||||
}
|
||||
payURL, err = h.wxpayService.Pay(params)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
package handler
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
"geekai/service"
|
||||
"geekai/service/ppt"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PPTTaskHandler 用户侧 PPT 生成任务处理器(薄层:参数、鉴权、调用 PptService)
|
||||
type PPTTaskHandler struct {
|
||||
BaseHandler
|
||||
snowflake *service.Snowflake
|
||||
pptService *ppt.PptService
|
||||
}
|
||||
|
||||
// NewPPTTaskHandler 创建 PPT 任务处理器
|
||||
func NewPPTTaskHandler(app *core.AppServer, db *gorm.DB, snowflake *service.Snowflake, pptService *ppt.PptService) *PPTTaskHandler {
|
||||
return &PPTTaskHandler{
|
||||
BaseHandler: BaseHandler{App: app, DB: db},
|
||||
snowflake: snowflake,
|
||||
pptService: pptService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册 PPT 任务相关路由
|
||||
func (h *PPTTaskHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/v1/tasks")
|
||||
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
|
||||
{
|
||||
group.POST("generate-slides", h.CreateTask)
|
||||
group.POST("generate-slides/from-file", h.CreateTaskFromFile)
|
||||
group.GET("", h.ListTasks) // 当前用户任务列表,须在 :task_id 前注册
|
||||
group.GET(":task_id/export", h.ExportTask)
|
||||
group.POST(":task_id/resume", h.ResumeTask)
|
||||
group.POST(":task_id/slides/:slide_index/edit-image", h.EditSlideImage)
|
||||
group.PATCH(":task_id/slides/:slide_index/active-image", h.SetActiveSlideImage)
|
||||
group.GET(":task_id", h.GetTask)
|
||||
group.DELETE(":task_id", h.DeleteTask)
|
||||
}
|
||||
}
|
||||
|
||||
type createPPTTaskRequest struct {
|
||||
Content string `json:"content"`
|
||||
Prompt string `json:"prompt"`
|
||||
Language string `json:"language"` // 如 zh-CN, en,约束分镜输出语言
|
||||
Pages int `json:"pages"` // 目标页数,0 表示用配置默认值
|
||||
Mode string `json:"mode"` // 生成模式:detailed=详细演示文稿,slides=演示用幻灯片,空则默认 slides
|
||||
}
|
||||
|
||||
type createPPTTaskFromFileRequest struct {
|
||||
FileURL string `json:"file_url"`
|
||||
Prompt string `json:"prompt"`
|
||||
Language string `json:"language"` // 如 zh-CN, en,约束分镜输出语言
|
||||
Pages int `json:"pages"` // 目标页数,0 表示用配置默认值
|
||||
Mode string `json:"mode"` // 生成模式:detailed=详细演示文稿,slides=演示用幻灯片,空则默认 slides
|
||||
}
|
||||
|
||||
// CreateTask 创建 PPT 生成任务(异步)
|
||||
func (h *PPTTaskHandler) CreateTask(c *gin.Context) {
|
||||
var req createPPTTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Content == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
taskID, err := h.snowflake.Next(true)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "生成任务ID失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
task, cfg, err := h.pptService.BuildPendingTask(taskID, user.Id, int(user.Power), req.Content, req.Prompt, req.Language, req.Mode, req.Pages)
|
||||
if err != nil {
|
||||
if errors.Is(err, ppt.ErrInsufficientPower) {
|
||||
resp.ERROR(c, "当前用户算力不足以完成本次 PPT 生成任务!")
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, "加载 PPT 配置或校验失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.pptService.CreateTask(c.Request.Context(), task, cfg)
|
||||
go h.pptService.RunTask(context.Background(), task, cfg)
|
||||
|
||||
resp.SUCCESS(c, map[string]any{
|
||||
"task_id": taskID,
|
||||
"status": ppt.TaskStatusPending,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateTaskFromFile 创建 PPT 生成任务(基于上传材料文本提炼)
|
||||
// 支持文件:PDF、Word(doc/docx)、TXT、Markdown(md/markdown)
|
||||
func (h *PPTTaskHandler) CreateTaskFromFile(c *gin.Context) {
|
||||
var req createPPTTaskFromFileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.FileURL) == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
fileURL := strings.TrimSpace(req.FileURL)
|
||||
|
||||
// 去掉 query,避免 ".pdf?xxx" 这种导致 ext 识别失败
|
||||
urlNoQuery := strings.Split(fileURL, "?")[0]
|
||||
ext := strings.ToLower(filepath.Ext(urlNoQuery))
|
||||
if ext == "" {
|
||||
resp.ERROR(c, "不支持的文件格式")
|
||||
return
|
||||
}
|
||||
|
||||
allowedExts := map[string]bool{
|
||||
".pdf": true,
|
||||
".doc": true,
|
||||
".docx": true,
|
||||
".txt": true,
|
||||
".md": true,
|
||||
".markdown": true,
|
||||
}
|
||||
if !allowedExts[ext] {
|
||||
resp.ERROR(c, "不支持的文件格式")
|
||||
return
|
||||
}
|
||||
|
||||
designPrompt := strings.TrimSpace(req.Prompt)
|
||||
language := strings.TrimSpace(req.Language)
|
||||
if language == "" {
|
||||
language = "zh-CN"
|
||||
}
|
||||
mode := strings.TrimSpace(req.Mode)
|
||||
|
||||
pages := req.Pages
|
||||
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
taskID, err := h.snowflake.Next(true)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "生成任务ID失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 1) 下载文件并提取原始文本(Notebook 风格提炼前的输入)
|
||||
rawText, err := h.extractMaterialTextFromURL(c.Request.Context(), fileURL, ext)
|
||||
if err != nil || strings.TrimSpace(rawText) == "" {
|
||||
if err == nil {
|
||||
err = errors.New("empty extracted text")
|
||||
}
|
||||
resp.ERROR(c, "读取文件失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 校验算力与页数,组装待写入的 Task(先在提炼前做 power 检查)
|
||||
task, cfg, err := h.pptService.BuildPendingTask(taskID, user.Id, int(user.Power), rawText, designPrompt, language, mode, pages)
|
||||
if err != nil {
|
||||
if errors.Is(err, ppt.ErrInsufficientPower) {
|
||||
resp.ERROR(c, "当前用户算力不足以完成本次 PPT 生成任务!")
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, "加载 PPT 配置或校验失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 3) NotebookLM 风格提炼:rawText -> PPT 可用的 content(大纲/结构化要点)
|
||||
notebookContent, err := h.pptService.GenerateNotebookContent(c.Request.Context(), cfg, rawText, designPrompt, language)
|
||||
if err != nil || strings.TrimSpace(notebookContent) == "" {
|
||||
if err == nil {
|
||||
err = errors.New("empty notebook content")
|
||||
}
|
||||
resp.ERROR(c, "提炼材料失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
task.Content = notebookContent
|
||||
|
||||
// 4) 落库 + 异步生成分镜与图片
|
||||
err = h.pptService.CreateTask(c.Request.Context(), task, cfg)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "创建任务失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
go h.pptService.RunTask(context.Background(), task, cfg)
|
||||
|
||||
resp.SUCCESS(c, map[string]any{
|
||||
"task_id": taskID,
|
||||
"status": ppt.TaskStatusPending,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PPTTaskHandler) extractMaterialTextFromURL(ctx context.Context, fileURL string, ext string) (string, error) {
|
||||
if strings.TrimSpace(fileURL) == "" {
|
||||
return "", errors.New("empty file url")
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".txt", ".md", ".markdown":
|
||||
b, status, err := utils.FetchURLBytes(ctx, fileURL, "", 30*time.Second, 2, 8<<20)
|
||||
if err != nil {
|
||||
// status=0 时通常是请求阶段错误(例如 TLS 握手超时)
|
||||
return "", fmt.Errorf("download file failed: status=%d: %w", status, err)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
default:
|
||||
// PDF/Word 等走 Tika
|
||||
return utils.ReadFileContent(fileURL, h.App.Config.TikaHost)
|
||||
}
|
||||
}
|
||||
|
||||
// ListTasks 当前用户的 PPT 任务列表(分页)
|
||||
func (h *PPTTaskHandler) ListTasks(c *gin.Context) {
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
page := h.GetInt(c, "page", 1)
|
||||
pageSize := h.GetInt(c, "page_size", 20)
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
slice, total := h.pptService.ListUserTasks(c.Request.Context(), user.Id, page, pageSize)
|
||||
|
||||
jobs := make([]map[string]any, 0, len(slice))
|
||||
for _, t := range slice {
|
||||
job := t.TaskSummaryMap()
|
||||
job["prompt"] = t.Prompt
|
||||
if t.ErrorMessage != "" {
|
||||
job["error_message"] = t.ErrorMessage
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, map[string]any{
|
||||
"jobs": jobs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// ExportTask 导出任务幻灯片为 PDF 或 PPTX(按图片逐页)
|
||||
func (h *PPTTaskHandler) ExportTask(c *gin.Context) {
|
||||
taskID := strings.TrimSpace(c.Param("task_id"))
|
||||
if taskID == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
ef, ok := ppt.ParseExportFormat(c.Query("format"))
|
||||
if !ok {
|
||||
resp.ERROR(c, "format 参数无效,支持 pdf 或 pptx")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
task, exists := h.pptService.GetTask(taskID)
|
||||
if !exists {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
return
|
||||
}
|
||||
if user.Id != task.UserID {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
if task.Status != ppt.TaskStatusCompleted {
|
||||
resp.ERROR(c, "仅已完成任务可导出")
|
||||
return
|
||||
}
|
||||
|
||||
ossCfg := types.OSSConfig{}
|
||||
if h.App.SysConfig != nil {
|
||||
ossCfg = h.App.SysConfig.OSS
|
||||
}
|
||||
data, err := ppt.BuildExportBytes(c.Request.Context(), task.Slides, ef, ossCfg, h.App.Config)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
base := ppt.SanitizeExportBaseName(task.Title, task.TaskID)
|
||||
filename := base + ppt.ExportFileExt(ef)
|
||||
c.Header("Content-Disposition", ppt.ContentDispositionAttachment(filename))
|
||||
c.Data(http.StatusOK, ppt.ExportMimeType(ef), data)
|
||||
}
|
||||
|
||||
// ResumeTask 继续生成缺图页(POST /api/v1/tasks/:task_id/resume)
|
||||
func (h *PPTTaskHandler) ResumeTask(c *gin.Context) {
|
||||
taskID := strings.TrimSpace(c.Param("task_id"))
|
||||
if taskID == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.pptService.ResumeTask(c.Request.Context(), taskID, user.Id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ppt.ErrPptTaskNotFound) {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ppt.ErrPptTaskBusy) {
|
||||
resp.ERROR(c, "任务正在处理中,请稍后再试")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ppt.ErrPptTaskNotResumable) {
|
||||
resp.ERROR(c, "当前任务无法继续生成(已完成或分镜数据不完整)")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ppt.ErrInsufficientPower) {
|
||||
resp.ERROR(c, "当前用户算力不足以完成剩余图片生成")
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, map[string]any{
|
||||
"task_id": taskID,
|
||||
"status": ppt.TaskStatusProcessing,
|
||||
})
|
||||
}
|
||||
|
||||
type editSlideImageRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
type activeSlideImageRequest struct {
|
||||
VersionIndex int `json:"version_index"`
|
||||
}
|
||||
|
||||
// EditSlideImage 图生图编辑当前页(基于激活图)
|
||||
func (h *PPTTaskHandler) EditSlideImage(c *gin.Context) {
|
||||
taskID := strings.TrimSpace(c.Param("task_id"))
|
||||
slideIndexStr := strings.TrimSpace(c.Param("slide_index"))
|
||||
if taskID == "" || slideIndexStr == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
slideIndex, err := strconv.Atoi(slideIndexStr)
|
||||
if err != nil || slideIndex < 1 {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
var req editSlideImageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
ossCfg := types.OSSConfig{}
|
||||
if h.App.SysConfig != nil {
|
||||
ossCfg = h.App.SysConfig.OSS
|
||||
}
|
||||
|
||||
slides, err := h.pptService.EditSlideImage(c.Request.Context(), taskID, user.Id, slideIndex, req.Prompt, ossCfg, h.App.Config)
|
||||
if err != nil {
|
||||
if errors.Is(err, ppt.ErrPptTaskNotFound) {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ppt.ErrPptSlideNotFound) {
|
||||
resp.ERROR(c, "幻灯片不存在")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ppt.ErrPptSlideNoImage) {
|
||||
resp.ERROR(c, "该页暂无配图,无法编辑")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ppt.ErrInsufficientPower) {
|
||||
resp.ERROR(c, "当前用户算力不足以完成本次编辑")
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, map[string]any{"slides": slides})
|
||||
}
|
||||
|
||||
// SetActiveSlideImage 切换当前页激活的历史版本
|
||||
func (h *PPTTaskHandler) SetActiveSlideImage(c *gin.Context) {
|
||||
taskID := strings.TrimSpace(c.Param("task_id"))
|
||||
slideIndexStr := strings.TrimSpace(c.Param("slide_index"))
|
||||
if taskID == "" || slideIndexStr == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
slideIndex, err := strconv.Atoi(slideIndexStr)
|
||||
if err != nil || slideIndex < 1 {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
var req activeSlideImageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
slides, err := h.pptService.SetActiveSlideVersion(taskID, user.Id, slideIndex, req.VersionIndex)
|
||||
if err != nil {
|
||||
if errors.Is(err, ppt.ErrPptTaskNotFound) {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ppt.ErrPptSlideNotFound) {
|
||||
resp.ERROR(c, "幻灯片不存在")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ppt.ErrPptInvalidVersionIndex) {
|
||||
resp.ERROR(c, "无效的历史版本序号")
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, map[string]any{"slides": slides})
|
||||
}
|
||||
|
||||
// GetTask 查询 PPT 任务进度
|
||||
func (h *PPTTaskHandler) GetTask(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
task, ok := h.pptService.GetTask(taskId)
|
||||
if !ok {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil || user.Id != task.UserID {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
percentage := 0
|
||||
if task.Total > 0 {
|
||||
percentage = int(float64(task.Completed) / float64(task.Total) * 100)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, map[string]any{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
"progress": map[string]any{
|
||||
"total_slides": task.Total,
|
||||
"completed_slides": task.Completed,
|
||||
"percentage": percentage,
|
||||
},
|
||||
"slides": task.Slides,
|
||||
"error_message": task.ErrorMessage,
|
||||
"content": task.Content,
|
||||
"prompt": task.Prompt,
|
||||
"title": task.Title,
|
||||
"thumb": task.Thumb,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTask 删除用户 PPT 任务(仅允许 completed / failed),同时删除该任务生成的图片对象。
|
||||
func (h *PPTTaskHandler) DeleteTask(c *gin.Context) {
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.pptService.DeleteTask(taskID, user.Id); err != nil {
|
||||
if errors.Is(err, ppt.ErrPptTaskNotFound) {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ppt.ErrPptTaskNotDeletable) {
|
||||
resp.ERROR(c, "仅允许删除已完成/已失败任务")
|
||||
return
|
||||
}
|
||||
// 前端会统一在文案里拼接“删除失败:”,这里避免重复。
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, gin.H{"message": "删除成功"})
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func (h *RealtimeHandler) VoiceChat(c *gin.Context) {
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s/v1/chat/completions", apiKey.ApiURL)
|
||||
logger.Infof("Sending %s request, API KEY:%s, PROXY: %s, Model: %s", apiKey.ApiURL, apiURL, apiKey.ProxyURL, "advanced-voice")
|
||||
r, err := client.R().SetHeader("Body-Type", "application/json").
|
||||
r, err := client.R().SetHeader("Content-Type", "application/json").
|
||||
SetHeader("Authorization", "Bearer "+apiKey.Value).
|
||||
SetBody(types.ApiRequest{
|
||||
Model: "advanced-voice",
|
||||
@@ -221,11 +221,12 @@ func (h *RealtimeHandler) VoiceChat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("Response: %v", response.Choices[0].Message.Content)
|
||||
replyText := utils.NormalizeAssistantContent(response.Choices[0].Message.Content)
|
||||
logger.Infof("Response: %v", replyText)
|
||||
|
||||
// 提取链接
|
||||
re := regexp.MustCompile(`\[(.*?)\]\((.*?)\)`)
|
||||
links := re.FindAllStringSubmatch(response.Choices[0].Message.Content, -1)
|
||||
links := re.FindAllStringSubmatch(replyText, -1)
|
||||
var url = ""
|
||||
if len(links) > 0 {
|
||||
url = links[0][2]
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
package handler
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
"geekai/service"
|
||||
"geekai/service/moderation"
|
||||
"geekai/service/oss"
|
||||
"geekai/service/sd"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SdJobHandler struct {
|
||||
BaseHandler
|
||||
redis *redis.Client
|
||||
sdService *sd.Service
|
||||
uploader *oss.UploaderManager
|
||||
snowflake *service.Snowflake
|
||||
leveldb *store.LevelDB
|
||||
userService *service.UserService
|
||||
moderationManager *moderation.ServiceManager
|
||||
}
|
||||
|
||||
func NewSdJobHandler(app *core.AppServer,
|
||||
db *gorm.DB,
|
||||
service *sd.Service,
|
||||
manager *oss.UploaderManager,
|
||||
snowflake *service.Snowflake,
|
||||
userService *service.UserService,
|
||||
levelDB *store.LevelDB,
|
||||
moderationManager *moderation.ServiceManager) *SdJobHandler {
|
||||
return &SdJobHandler{
|
||||
sdService: service,
|
||||
uploader: manager,
|
||||
snowflake: snowflake,
|
||||
leveldb: levelDB,
|
||||
userService: userService,
|
||||
moderationManager: moderationManager,
|
||||
BaseHandler: BaseHandler{
|
||||
App: app,
|
||||
DB: db,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *SdJobHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/sd/")
|
||||
|
||||
// 公开接口,不需要授权
|
||||
group.GET("imgWall", h.ImgWall)
|
||||
|
||||
// 需要用户授权的接口
|
||||
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
|
||||
{
|
||||
group.POST("image", h.Image)
|
||||
group.GET("jobs", h.JobList)
|
||||
group.GET("remove", h.Remove)
|
||||
group.GET("publish", h.Publish)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SdJobHandler) preCheck(c *gin.Context) bool {
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return false
|
||||
}
|
||||
|
||||
if user.Power < h.App.SysConfig.Base.SdPower {
|
||||
resp.ERROR(c, "当前用户剩余算力不足以完成本次绘画!")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
}
|
||||
|
||||
// Image 创建一个绘画任务
|
||||
func (h *SdJobHandler) Image(c *gin.Context) {
|
||||
if !h.preCheck(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var data types.SdTaskParams
|
||||
if err := c.ShouldBindJSON(&data); err != nil || data.Prompt == "" {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
if h.App.SysConfig.Moderation.Enable {
|
||||
moderationResult, err := h.moderationManager.GetService().Moderate(data.Prompt)
|
||||
if err != nil {
|
||||
logger.Error("failed to moderate content: ", err)
|
||||
}
|
||||
if moderationResult.Flagged {
|
||||
// 记录违规内容
|
||||
moderation := model.Moderation{
|
||||
UserId: h.GetLoginUserId(c),
|
||||
Source: types.ModerationSourceSD,
|
||||
Input: data.Prompt,
|
||||
Result: utils.JsonEncode(moderationResult),
|
||||
}
|
||||
err = h.DB.Create(&moderation).Error
|
||||
if err != nil {
|
||||
logger.Error("failed to save moderation: ", err)
|
||||
}
|
||||
resp.ERROR(c, "当前创作内容包含敏感词,请重新输入!")
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if data.Width <= 0 {
|
||||
data.Width = 512
|
||||
}
|
||||
if data.Height <= 0 {
|
||||
data.Height = 512
|
||||
}
|
||||
if data.CfgScale <= 0 {
|
||||
data.CfgScale = 7
|
||||
}
|
||||
if data.Seed == 0 {
|
||||
data.Seed = -1
|
||||
}
|
||||
if data.Steps <= 0 {
|
||||
data.Steps = 20
|
||||
}
|
||||
if data.Sampler == "" {
|
||||
data.Sampler = "Euler a"
|
||||
}
|
||||
|
||||
idValue, _ := c.Get(types.LoginUserID)
|
||||
userId := utils.IntValue(utils.InterfaceToString(idValue), 0)
|
||||
taskId, err := h.snowflake.Next(true)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "error with generate task id: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
task := types.SdTask{
|
||||
Type: types.TaskImage,
|
||||
Params: types.SdTaskParams{
|
||||
TaskId: taskId,
|
||||
Prompt: data.Prompt,
|
||||
NegPrompt: data.NegPrompt,
|
||||
Steps: data.Steps,
|
||||
Sampler: data.Sampler,
|
||||
FaceFix: data.FaceFix,
|
||||
CfgScale: data.CfgScale,
|
||||
Seed: data.Seed,
|
||||
Height: data.Height,
|
||||
Width: data.Width,
|
||||
HdFix: data.HdFix,
|
||||
HdRedrawRate: data.HdRedrawRate,
|
||||
HdScale: data.HdScale,
|
||||
HdScaleAlg: data.HdScaleAlg,
|
||||
HdSteps: data.HdSteps,
|
||||
},
|
||||
UserId: userId,
|
||||
TranslateModelId: h.App.SysConfig.Base.AssistantModelId,
|
||||
}
|
||||
|
||||
job := model.SdJob{
|
||||
UserId: uint(userId),
|
||||
Type: types.TaskImage.String(),
|
||||
TaskId: taskId,
|
||||
Params: utils.JsonEncode(task.Params),
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Prompt: data.Prompt,
|
||||
Progress: 0,
|
||||
Power: h.App.SysConfig.Base.SdPower,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
res := h.DB.Create(&job)
|
||||
if res.Error != nil {
|
||||
resp.ERROR(c, "error with save job: "+res.Error.Error())
|
||||
return
|
||||
}
|
||||
|
||||
task.Id = int(job.Id)
|
||||
h.sdService.PushTask(task)
|
||||
|
||||
// update user's power
|
||||
err = h.userService.DecreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerConsume,
|
||||
Model: "stable-diffusion",
|
||||
Remark: fmt.Sprintf("绘图操作,任务ID:%s", job.TaskId),
|
||||
})
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
|
||||
// ImgWall 照片墙
|
||||
func (h *SdJobHandler) ImgWall(c *gin.Context) {
|
||||
page := h.GetInt(c, "page", 0)
|
||||
pageSize := h.GetInt(c, "page_size", 0)
|
||||
err, jobs := h.getData(true, 0, page, pageSize, true)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, jobs)
|
||||
}
|
||||
|
||||
// JobList 获取 SD 任务列表
|
||||
func (h *SdJobHandler) JobList(c *gin.Context) {
|
||||
finish := h.GetBool(c, "finish")
|
||||
userId := h.GetLoginUserId(c)
|
||||
page := h.GetInt(c, "page", 0)
|
||||
pageSize := h.GetInt(c, "page_size", 0)
|
||||
publish := h.GetBool(c, "publish")
|
||||
|
||||
err, jobs := h.getData(finish, userId, page, pageSize, publish)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, jobs)
|
||||
}
|
||||
|
||||
// JobList 获取 MJ 任务列表
|
||||
func (h *SdJobHandler) getData(finish bool, userId uint, page int, pageSize int, publish bool) (error, vo.Page) {
|
||||
|
||||
session := h.DB.Session(&gorm.Session{})
|
||||
if finish {
|
||||
session = session.Where("progress >= ?", 100).Order("id DESC")
|
||||
} else {
|
||||
session = session.Where("progress < ?", 100).Order("id ASC")
|
||||
}
|
||||
if userId > 0 {
|
||||
session = session.Where("user_id = ?", userId)
|
||||
}
|
||||
if publish {
|
||||
session = session.Where("publish", publish)
|
||||
}
|
||||
if page > 0 && pageSize > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
session = session.Offset(offset).Limit(pageSize)
|
||||
}
|
||||
|
||||
// 统计总数
|
||||
var total int64
|
||||
session.Model(&model.SdJob{}).Count(&total)
|
||||
|
||||
var items []model.SdJob
|
||||
res := session.Find(&items)
|
||||
if res.Error != nil {
|
||||
return res.Error, vo.Page{}
|
||||
}
|
||||
|
||||
var jobs = make([]vo.SdJob, 0)
|
||||
for _, item := range items {
|
||||
var job vo.SdJob
|
||||
err := utils.CopyObject(item, &job)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
return nil, vo.NewPage(total, page, pageSize, jobs)
|
||||
}
|
||||
|
||||
// Remove remove task image
|
||||
func (h *SdJobHandler) Remove(c *gin.Context) {
|
||||
id := h.GetInt(c, "id", 0)
|
||||
userId := h.GetLoginUserId(c)
|
||||
var job model.SdJob
|
||||
if res := h.DB.Where("id = ? AND user_id = ?", id, userId).First(&job); res.Error != nil {
|
||||
resp.ERROR(c, "记录不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
err := h.DB.Delete(&job).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// remove image
|
||||
err = h.uploader.GetUploadHandler().Delete(job.ImgURL)
|
||||
if err != nil {
|
||||
logger.Error("remove image failed: ", err)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
|
||||
// Publish 发布/取消发布图片到画廊显示
|
||||
func (h *SdJobHandler) Publish(c *gin.Context) {
|
||||
id := h.GetInt(c, "id", 0)
|
||||
userId := h.GetLoginUserId(c)
|
||||
action := h.GetBool(c, "action") // 发布动作,true => 发布,false => 取消分享
|
||||
|
||||
err := h.DB.Model(&model.SdJob{Id: uint(id), UserId: uint(userId)}).UpdateColumn("publish", action).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
+22
-18
@@ -125,9 +125,9 @@ func (h *SunoHandler) Create(c *gin.Context) {
|
||||
if data.SongId != "" && data.Type == 3 {
|
||||
var song model.SunoJob
|
||||
if err := h.DB.Where("song_id = ?", data.SongId).First(&song).Error; err == nil {
|
||||
data.Instrumental = song.Instrumental
|
||||
data.Model = song.ModelName
|
||||
data.Tags = song.Tags
|
||||
data.Instrumental = song.Params.Instrumental
|
||||
data.Model = song.Params.Model
|
||||
data.Tags = song.Params.Tags
|
||||
}
|
||||
// 拼接歌词
|
||||
var refSong model.SunoJob
|
||||
@@ -153,22 +153,26 @@ func (h *SunoHandler) Create(c *gin.Context) {
|
||||
|
||||
// 插入数据库
|
||||
job := model.SunoJob{
|
||||
UserId: uint(task.UserId),
|
||||
Prompt: data.Prompt,
|
||||
Instrumental: data.Instrumental,
|
||||
ModelName: data.Model,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Tags: data.Tags,
|
||||
Title: data.Title,
|
||||
Type: data.Type,
|
||||
RefSongId: data.RefSongId,
|
||||
RefTaskId: data.RefTaskId,
|
||||
ExtendSecs: data.ExtendSecs,
|
||||
Power: h.App.SysConfig.Base.SunoPower,
|
||||
SongId: utils.RandString(32),
|
||||
UserId: uint(task.UserId),
|
||||
Prompt: data.Prompt,
|
||||
Params: vo.SunoParam{
|
||||
Prompt: data.Prompt,
|
||||
Instrumental: data.Instrumental,
|
||||
Tags: data.Tags,
|
||||
ExtendSecs: data.ExtendSecs,
|
||||
Lyrics: data.Lyrics,
|
||||
Model: data.Model,
|
||||
},
|
||||
Title: data.Title,
|
||||
Type: data.Type,
|
||||
RefSongId: data.RefSongId,
|
||||
RefTaskId: data.RefTaskId,
|
||||
Power: h.App.SysConfig.Base.SunoPower,
|
||||
SongId: utils.RandString(32),
|
||||
}
|
||||
if data.Lyrics != "" {
|
||||
job.Prompt = data.Lyrics
|
||||
job.Params.Prompt = data.Lyrics
|
||||
}
|
||||
tx := h.DB.Create(&job)
|
||||
if tx.Error != nil {
|
||||
@@ -183,8 +187,8 @@ func (h *SunoHandler) Create(c *gin.Context) {
|
||||
// update user's power
|
||||
err = h.userService.DecreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerConsume,
|
||||
Model: job.ModelName,
|
||||
Remark: fmt.Sprintf("Suno 文生歌曲,%s", job.ModelName),
|
||||
Model: job.Params.Model,
|
||||
Remark: fmt.Sprintf("Suno 文生歌曲,%s", job.Params.Model),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
+139
-9
@@ -39,6 +39,7 @@ type UserHandler struct {
|
||||
userService *service.UserService
|
||||
wxLoginService *service.WxLoginService
|
||||
ipSearcher *xdb.Searcher
|
||||
wechatService *service.WxGzhService
|
||||
}
|
||||
|
||||
func NewUserHandler(
|
||||
@@ -50,6 +51,7 @@ func NewUserHandler(
|
||||
captcha *service.CaptchaService,
|
||||
userService *service.UserService,
|
||||
wxLoginService *service.WxLoginService,
|
||||
wechatService *service.WxGzhService,
|
||||
ipSearcher *xdb.Searcher) *UserHandler {
|
||||
return &UserHandler{
|
||||
BaseHandler: BaseHandler{DB: db, App: app},
|
||||
@@ -59,6 +61,7 @@ func NewUserHandler(
|
||||
captchaService: captcha,
|
||||
userService: userService,
|
||||
wxLoginService: wxLoginService,
|
||||
wechatService: wechatService,
|
||||
ipSearcher: ipSearcher,
|
||||
}
|
||||
}
|
||||
@@ -75,6 +78,7 @@ func (h *UserHandler) RegisterRoutes() {
|
||||
group.POST("login/callback", h.WxLoginCallback)
|
||||
group.GET("login/status", h.GetWxLoginState)
|
||||
group.GET("logout", h.Logout)
|
||||
group.POST("wxAuthLogin", h.WxAuthLogin)
|
||||
|
||||
// 需要用户授权的接口
|
||||
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
|
||||
@@ -319,11 +323,22 @@ func (h *UserHandler) GetWxLoginState(c *gin.Context) {
|
||||
func (h *UserHandler) createNewUser(user model.User, code string) (model.User, error) {
|
||||
if user.OpenId != "" {
|
||||
user.Platform = "wechat"
|
||||
user.Nickname = fmt.Sprintf("微信用户@%d", utils.RandomNumber(6))
|
||||
user.Username = fmt.Sprintf("wx@%d", utils.RandomNumber(8))
|
||||
user.Password = "geekai123"
|
||||
// 如果未设置昵称,则生成默认昵称
|
||||
if user.Nickname == "" {
|
||||
user.Nickname = fmt.Sprintf("微信用户@%d", utils.RandomNumber(6))
|
||||
}
|
||||
// 如果未设置用户名,则生成默认用户名
|
||||
if user.Username == "" {
|
||||
user.Username = fmt.Sprintf("wx@%d", utils.RandomNumber(8))
|
||||
}
|
||||
// 如果未设置密码,则生成默认密码
|
||||
if user.Password == "" {
|
||||
user.Password = "geekai123"
|
||||
}
|
||||
} else {
|
||||
user.Nickname = fmt.Sprintf("用户@%d", utils.RandomNumber(6))
|
||||
if user.Nickname == "" {
|
||||
user.Nickname = fmt.Sprintf("用户@%d", utils.RandomNumber(6))
|
||||
}
|
||||
if user.Username == "" || user.Password == "" {
|
||||
return user, fmt.Errorf("用户名或密码不能为空")
|
||||
}
|
||||
@@ -332,9 +347,11 @@ func (h *UserHandler) createNewUser(user model.User, code string) (model.User, e
|
||||
salt := utils.RandString(8)
|
||||
user.Salt = salt
|
||||
user.Password = utils.GenPassword(user.Password, salt)
|
||||
user.Avatar = "/images/avatar/user.png"
|
||||
// 如果未设置头像,则使用默认头像
|
||||
if user.Avatar == "" {
|
||||
user.Avatar = "/images/avatar/user.png"
|
||||
}
|
||||
user.Status = true
|
||||
user.ChatRoles = utils.JsonEncode([]string{"gpt"})
|
||||
user.ChatConfig = "{}"
|
||||
user.ChatModels = "{}"
|
||||
user.Power = h.App.SysConfig.Base.InitPower
|
||||
@@ -471,6 +488,20 @@ func (h *UserHandler) Session(c *gin.Context) {
|
||||
h.DB.Model(&user).UpdateColumn("vip", false)
|
||||
}
|
||||
userVo.Id = user.Id
|
||||
// 工作区应用 ID 列表(历史可能为 key 数组,仅解析数字 ID)
|
||||
if user.ChatRoles != "" {
|
||||
var raw []interface{}
|
||||
if utils.JsonDecode(user.ChatRoles, &raw) == nil {
|
||||
for _, v := range raw {
|
||||
if n, ok := v.(float64); ok && n >= 0 {
|
||||
userVo.ChatRoles = append(userVo.ChatRoles, uint(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if userVo.ChatRoles == nil {
|
||||
userVo.ChatRoles = []uint{}
|
||||
}
|
||||
resp.SUCCESS(c, userVo)
|
||||
|
||||
}
|
||||
@@ -483,6 +514,7 @@ type userProfile struct {
|
||||
Power int `json:"power"`
|
||||
ExpiredTime int64 `json:"expired_time"`
|
||||
Vip bool `json:"vip"`
|
||||
GemIds []uint `json:"gem_ids"`
|
||||
}
|
||||
|
||||
func (h *UserHandler) Profile(c *gin.Context) {
|
||||
@@ -502,6 +534,19 @@ func (h *UserHandler) Profile(c *gin.Context) {
|
||||
}
|
||||
|
||||
profile.Id = user.Id
|
||||
if user.GemIds != "" {
|
||||
var raw []interface{}
|
||||
if utils.JsonDecode(user.GemIds, &raw) == nil {
|
||||
for _, v := range raw {
|
||||
if n, ok := v.(float64); ok {
|
||||
profile.GemIds = append(profile.GemIds, uint(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if profile.GemIds == nil {
|
||||
profile.GemIds = []uint{}
|
||||
}
|
||||
resp.SUCCESS(c, profile)
|
||||
}
|
||||
|
||||
@@ -520,6 +565,12 @@ func (h *UserHandler) ProfileUpdate(c *gin.Context) {
|
||||
h.DB.First(&user, user.Id)
|
||||
user.Avatar = data.Avatar
|
||||
user.Nickname = data.Nickname
|
||||
if data.GemIds != nil {
|
||||
if len(data.GemIds) > 8 {
|
||||
data.GemIds = data.GemIds[:8]
|
||||
}
|
||||
user.GemIds = utils.JsonEncode(data.GemIds)
|
||||
}
|
||||
res := h.DB.Updates(&user)
|
||||
if res.Error != nil {
|
||||
resp.ERROR(c, "更新用户信息失败")
|
||||
@@ -584,13 +635,14 @@ func (h *UserHandler) ResetPass(c *gin.Context) {
|
||||
|
||||
session := h.DB.Session(&gorm.Session{})
|
||||
var key string
|
||||
if data.Type == "email" {
|
||||
switch data.Type {
|
||||
case "email":
|
||||
session = session.Where("email", data.Email)
|
||||
key = CodeStorePrefix + data.Email
|
||||
} else if data.Type == "mobile" {
|
||||
case "mobile":
|
||||
session = session.Where("mobile", data.Mobile)
|
||||
key = CodeStorePrefix + data.Mobile
|
||||
} else {
|
||||
default:
|
||||
resp.ERROR(c, "验证类别错误")
|
||||
return
|
||||
}
|
||||
@@ -722,3 +774,81 @@ func (h *UserHandler) SignIn(c *gin.Context) {
|
||||
}
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
|
||||
// 微信公众号 小程序授权登录
|
||||
func (h *UserHandler) WxAuthLogin(c *gin.Context) {
|
||||
var data struct {
|
||||
Code string `json:"code"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 code 获取 openid
|
||||
openID, accessToken, err := h.wechatService.GetOpenIDByCode(data.Code)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取微信用户昵称头像
|
||||
userInfo, err := h.wechatService.GetUserInfo(accessToken, openID)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
nickname := userInfo["nickname"].(string)
|
||||
headimgurl := userInfo["headimgurl"].(string)
|
||||
|
||||
// 查询用户是否存在
|
||||
var user model.User
|
||||
h.DB.Where("openid = ?", openID).First(&user)
|
||||
if user.Id > 0 {
|
||||
// 用户存在,更新用户信息并登录
|
||||
user.Nickname = nickname
|
||||
user.Avatar = headimgurl
|
||||
if err := h.DB.Save(&user).Error; err != nil {
|
||||
resp.ERROR(c, "更新用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.doLogin(&user, c.ClientIP())
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, gin.H{"token": token, "user_id": user.Id, "username": user.Username})
|
||||
return
|
||||
}
|
||||
|
||||
// 用户不存在,创建新用户
|
||||
user = model.User{
|
||||
OpenId: openID,
|
||||
Nickname: nickname,
|
||||
Avatar: headimgurl,
|
||||
}
|
||||
|
||||
// 被邀请人也获得赠送算力
|
||||
if data.InviteCode != "" {
|
||||
user.Power = h.App.SysConfig.Base.InitPower * 2
|
||||
}
|
||||
|
||||
user, err = h.createNewUser(user, data.InviteCode)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 自动登录
|
||||
token, err := h.doLogin(&user, c.ClientIP())
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, gin.H{"token": token, "user_id": user.Id, "username": user.Username})
|
||||
}
|
||||
|
||||
+127
-155
@@ -20,7 +20,6 @@ import (
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -54,33 +53,50 @@ func (h *VideoHandler) RegisterRoutes() {
|
||||
// 需要用户授权的接口
|
||||
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
|
||||
{
|
||||
group.POST("luma/create", h.LumaCreate)
|
||||
group.POST("keling/create", h.KeLingCreate)
|
||||
group.POST("create", h.Create)
|
||||
group.GET("list", h.List)
|
||||
group.GET("remove", h.Remove)
|
||||
group.GET("publish", h.Publish)
|
||||
group.GET("power-config", h.GetPowerConfig) // 获取算力配置
|
||||
group.GET("power-by-key", h.GetPowerByPriceKey) // 根据 priceKey 获取算力
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VideoHandler) LumaCreate(c *gin.Context) {
|
||||
type VideoTaskRequest struct {
|
||||
Provider string `json:"provider"` // 服务提供商(不带版本号:veo, sora)
|
||||
Model string `json:"model"` // 模型标识(带版本号:veo-2.0, sora-2.0)
|
||||
Prompt string `json:"prompt"` // 提示词
|
||||
Params map[string]any `json:"params"` // 模型特定参数
|
||||
PriceKey string `json:"price_key"` // 价格键(如 "fixed", "5_720P" 等)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Prompt string `json:"prompt"`
|
||||
FirstFrameImg string `json:"first_frame_img,omitempty"`
|
||||
EndFrameImg string `json:"end_frame_img,omitempty"`
|
||||
ExpandPrompt bool `json:"expand_prompt,omitempty"`
|
||||
Loop bool `json:"loop,omitempty"`
|
||||
}
|
||||
// Create 统一的创建视频任务接口
|
||||
func (h *VideoHandler) Create(c *gin.Context) {
|
||||
var data VideoTaskRequest
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
// 检查 Prompt 长度
|
||||
|
||||
// 验证必填字段
|
||||
if data.Provider == "" {
|
||||
resp.ERROR(c, "provider 不能为空")
|
||||
return
|
||||
}
|
||||
if data.Model == "" {
|
||||
resp.ERROR(c, "model 不能为空")
|
||||
return
|
||||
}
|
||||
if data.Prompt == "" {
|
||||
resp.ERROR(c, "prompt is needed")
|
||||
resp.ERROR(c, "prompt 不能为空")
|
||||
return
|
||||
}
|
||||
if data.PriceKey == "" {
|
||||
resp.ERROR(c, "price_key 不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 文本审查
|
||||
if h.App.SysConfig.Moderation.Enable {
|
||||
moderationResult, err := h.moderationManager.GetService().Moderate(data.Prompt)
|
||||
if err != nil {
|
||||
@@ -101,138 +117,45 @@ func (h *VideoHandler) LumaCreate(c *gin.Context) {
|
||||
resp.ERROR(c, "当前创作内容包含敏感词,请重新输入!")
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
if user.Power < h.App.SysConfig.Base.LumaPower {
|
||||
resp.ERROR(c, "您的算力不足,请充值后再试!")
|
||||
return
|
||||
}
|
||||
|
||||
userId := int(h.GetLoginUserId(c))
|
||||
params := types.LumaVideoParams{
|
||||
PromptOptimize: data.ExpandPrompt,
|
||||
Loop: data.Loop,
|
||||
StartImgURL: data.FirstFrameImg,
|
||||
EndImgURL: data.EndFrameImg,
|
||||
}
|
||||
task := types.VideoTask{
|
||||
UserId: userId,
|
||||
Type: types.VideoLuma,
|
||||
Prompt: data.Prompt,
|
||||
Params: params,
|
||||
TranslateModelId: h.App.SysConfig.Base.AssistantModelId,
|
||||
}
|
||||
// 插入数据库
|
||||
job := model.VideoJob{
|
||||
UserId: uint(userId),
|
||||
Type: types.VideoLuma,
|
||||
Prompt: data.Prompt,
|
||||
Power: h.App.SysConfig.Base.LumaPower,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
}
|
||||
tx := h.DB.Create(&job)
|
||||
if tx.Error != nil {
|
||||
resp.ERROR(c, tx.Error.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
task.Id = job.Id
|
||||
h.videoService.PushTask(task)
|
||||
|
||||
// update user's power
|
||||
err = h.userService.DecreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerConsume,
|
||||
Model: "luma",
|
||||
Remark: fmt.Sprintf("Luma 文生视频,任务ID:%d", job.Id),
|
||||
})
|
||||
// 计算算力
|
||||
power, err := video.CalculatePower(h.DB, data.Model, data.PriceKey)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
|
||||
func (h *VideoHandler) KeLingCreate(c *gin.Context) {
|
||||
|
||||
var data struct {
|
||||
Channel string `json:"channel"`
|
||||
TaskType string `json:"task_type"` // 任务类型: text2video/image2video
|
||||
Model string `json:"model"` // 模型: kling-v1-5,kling-v1-6
|
||||
Prompt string `json:"prompt"` // 视频描述
|
||||
NegPrompt string `json:"negative_prompt"` // 负面提示词
|
||||
CfgScale float64 `json:"cfg_scale"` // 相关性系数(0-1)
|
||||
Mode string `json:"mode"` // 生成模式: std/pro
|
||||
AspectRatio string `json:"aspect_ratio"` // 画面比例: 16:9/9:16/1:1
|
||||
Duration string `json:"duration"` // 视频时长: 5/10
|
||||
CameraControl types.CameraControl `json:"camera_control"` // 摄像机控制
|
||||
Image string `json:"image"` // 参考图片URL(image2video)
|
||||
ImageTail string `json:"image_tail"` // 尾帧图片URL(image2video)
|
||||
}
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算当前任务所需算力
|
||||
key := fmt.Sprintf("%s_%s_%s", data.Model, data.Mode, data.Duration)
|
||||
power := h.App.SysConfig.Base.KeLingPowers[key]
|
||||
if power == 0 {
|
||||
resp.ERROR(c, "当前模型暂不支持")
|
||||
return
|
||||
}
|
||||
// 检查算力是否充足
|
||||
if user.Power < power {
|
||||
resp.ERROR(c, "您的算力不足,请充值后再试!")
|
||||
return
|
||||
}
|
||||
|
||||
if data.Prompt == "" {
|
||||
resp.ERROR(c, "prompt is needed")
|
||||
return
|
||||
}
|
||||
|
||||
// 构建任务
|
||||
userId := int(h.GetLoginUserId(c))
|
||||
params := types.KeLingVideoParams{
|
||||
TaskType: data.TaskType,
|
||||
Model: data.Model,
|
||||
Prompt: data.Prompt,
|
||||
NegPrompt: data.NegPrompt,
|
||||
CfgScale: data.CfgScale,
|
||||
Mode: data.Mode,
|
||||
AspectRatio: data.AspectRatio,
|
||||
Duration: data.Duration,
|
||||
CameraControl: data.CameraControl,
|
||||
Image: data.Image,
|
||||
ImageTail: data.ImageTail,
|
||||
}
|
||||
task := types.VideoTask{
|
||||
UserId: userId,
|
||||
Type: types.VideoKeLing,
|
||||
Type: data.Provider, // provider 作为 type
|
||||
Prompt: data.Prompt,
|
||||
Params: params,
|
||||
Params: data.Params,
|
||||
TranslateModelId: h.App.SysConfig.Base.AssistantModelId,
|
||||
Channel: data.Channel,
|
||||
}
|
||||
|
||||
// 插入数据库
|
||||
job := model.VideoJob{
|
||||
UserId: uint(userId),
|
||||
Type: types.VideoKeLing,
|
||||
Prompt: data.Prompt,
|
||||
Power: power,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
UserId: uint(userId),
|
||||
Type: data.Provider,
|
||||
Prompt: data.Prompt,
|
||||
Power: power,
|
||||
Params: utils.JsonEncode(task),
|
||||
}
|
||||
tx := h.DB.Create(&job)
|
||||
if tx.Error != nil {
|
||||
@@ -244,17 +167,52 @@ func (h *VideoHandler) KeLingCreate(c *gin.Context) {
|
||||
task.Id = job.Id
|
||||
h.videoService.PushTask(task)
|
||||
|
||||
// update user's power
|
||||
// 扣减算力
|
||||
err = h.userService.DecreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerConsume,
|
||||
Model: "keling",
|
||||
Remark: fmt.Sprintf("keling 文生视频,任务ID:%d", job.Id),
|
||||
Model: data.Provider,
|
||||
Remark: fmt.Sprintf("%s 视频生成,任务ID:%d", data.Provider, job.Id),
|
||||
})
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c)
|
||||
|
||||
resp.SUCCESS(c, gin.H{"job_id": job.Id})
|
||||
}
|
||||
|
||||
// GetPowerConfig 获取算力配置
|
||||
func (h *VideoHandler) GetPowerConfig(c *gin.Context) {
|
||||
config, err := video.GetVideoConfig(h.DB)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, config.VideoPowers)
|
||||
}
|
||||
|
||||
// GetPowerByPriceKey 根据 modelKey 和 priceKey 获取算力值
|
||||
func (h *VideoHandler) GetPowerByPriceKey(c *gin.Context) {
|
||||
modelKey := c.Query("model_key")
|
||||
priceKey := c.Query("price_key")
|
||||
|
||||
if modelKey == "" {
|
||||
resp.ERROR(c, "model_key 不能为空")
|
||||
return
|
||||
}
|
||||
if priceKey == "" {
|
||||
resp.ERROR(c, "price_key 不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
power, err := video.CalculatePower(h.DB, modelKey, priceKey)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, gin.H{"power": power})
|
||||
}
|
||||
|
||||
func (h *VideoHandler) List(c *gin.Context) {
|
||||
@@ -268,7 +226,7 @@ func (h *VideoHandler) List(c *gin.Context) {
|
||||
session = session.Where("type", t)
|
||||
}
|
||||
if all {
|
||||
session = session.Where("publish", 0).Where("progress", 100)
|
||||
session = session.Where("publish", 0).Where("status", types.VideoStatusSuccess)
|
||||
} else {
|
||||
session = session.Where("user_id", userId)
|
||||
}
|
||||
@@ -296,36 +254,51 @@ func (h *VideoHandler) List(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
item.CreatedAt = v.CreatedAt.Unix()
|
||||
if item.VideoURL == "" {
|
||||
item.VideoURL = v.WaterURL
|
||||
}
|
||||
// 解析任务详情
|
||||
if item.Type == types.VideoKeLing {
|
||||
// 解析任务详情(用于前端展示标签)
|
||||
if v.Params != "" {
|
||||
task := types.VideoTask{}
|
||||
err = utils.JsonDecode(v.TaskInfo, &task)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var params types.KeLingVideoParams
|
||||
err = utils.JsonDecode(utils.JsonEncode(task.Params), ¶ms)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
item.RawData = map[string]interface{}{
|
||||
"task_type": params.TaskType,
|
||||
"model": params.Model,
|
||||
"cfg_scale": params.CfgScale,
|
||||
"mode": params.Mode,
|
||||
"aspect_ratio": params.AspectRatio,
|
||||
"duration": params.Duration,
|
||||
"model_name": fmt.Sprintf("%s_%s_%s", params.Model, params.Mode, params.Duration),
|
||||
}
|
||||
|
||||
// 如果视频URL不为空,则设置为生成成功
|
||||
if item.VideoURL != "" {
|
||||
item.Progress = 100
|
||||
if err := utils.JsonDecode(v.Params, &task); err == nil {
|
||||
// 默认从 params map 中提取常用字段
|
||||
if paramsMap, ok := task.Params.(map[string]any); ok {
|
||||
if item.Params == nil {
|
||||
item.Params = make(map[string]any)
|
||||
}
|
||||
if _, ok := item.Params["task_type"]; !ok {
|
||||
if taskType, ok := paramsMap["task_type"]; ok {
|
||||
item.Params["task_type"] = taskType
|
||||
}
|
||||
}
|
||||
if _, ok := item.Params["model"]; !ok {
|
||||
if modelKey, ok := paramsMap["model"]; ok {
|
||||
item.Params["model"] = modelKey
|
||||
}
|
||||
}
|
||||
if _, ok := item.Params["duration"]; !ok {
|
||||
if duration, ok := paramsMap["duration"]; ok {
|
||||
item.Params["duration"] = duration
|
||||
}
|
||||
}
|
||||
if _, ok := item.Params["size"]; !ok {
|
||||
if size, ok := paramsMap["size"]; ok {
|
||||
item.Params["size"] = size
|
||||
} else if size, ok := paramsMap["resolution"].(string); ok {
|
||||
item.Params["size"] = size
|
||||
}
|
||||
}
|
||||
if _, ok := item.Params["mode"]; !ok {
|
||||
if mode, ok := paramsMap["mode"]; ok {
|
||||
item.Params["mode"] = mode
|
||||
}
|
||||
}
|
||||
if _, ok := item.Params["sound"]; !ok {
|
||||
if sound, ok := paramsMap["sound"]; ok {
|
||||
item.Params["sound"] = sound
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
@@ -341,9 +314,9 @@ func (h *VideoHandler) Remove(c *gin.Context) {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
// 只有失败或者超时的任务才能删除
|
||||
if !(job.Progress == service.FailTaskProgress || time.Now().After(job.CreatedAt.Add(time.Minute*30))) {
|
||||
resp.ERROR(c, "只有失败和超时(30分钟)的任务才能删除!")
|
||||
// 只有失败的任务才能删除
|
||||
if job.Status != types.VideoStatusFailed {
|
||||
resp.ERROR(c, "只有失败的任务才能删除!")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -355,7 +328,6 @@ func (h *VideoHandler) Remove(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
_ = h.uploader.GetUploadHandler().Delete(job.CoverURL)
|
||||
_ = h.uploader.GetUploadHandler().Delete(job.VideoURL)
|
||||
|
||||
resp.SUCCESS(c)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package handler
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"geekai/core"
|
||||
"geekai/utils/resp"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WxGzhHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func NewWxGzhHandler(server *core.AppServer) *WxGzhHandler {
|
||||
return &WxGzhHandler{
|
||||
BaseHandler: BaseHandler{
|
||||
App: server,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WxGzhHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/wx/")
|
||||
group.GET("verify", h.WechatVerify)
|
||||
}
|
||||
|
||||
// 处理微信服务器验证请求
|
||||
func (h *WxGzhHandler) WechatVerify(c *gin.Context) {
|
||||
logger.Info("WechatVerify")
|
||||
// 只处理 GET 请求
|
||||
if c.Request.Method != "GET" {
|
||||
resp.ERROR(c, "Method Not Allowed")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 URL 参数
|
||||
signature := c.Query("signature")
|
||||
timestamp := c.Query("timestamp")
|
||||
nonce := c.Query("nonce")
|
||||
echostr := c.Query("echostr")
|
||||
|
||||
// 验证参数完整性
|
||||
if signature == "" || timestamp == "" || nonce == "" || echostr == "" {
|
||||
log.Println("Missing parameters")
|
||||
resp.ERROR(c, "Missing parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
if validateSignature(signature, h.App.SysConfig.WxGzh.Token, timestamp, nonce) {
|
||||
// 验证成功,返回 echostr(必须是纯文本)
|
||||
c.String(http.StatusOK, echostr)
|
||||
log.Println("Token verification success")
|
||||
} else {
|
||||
// 验证失败
|
||||
resp.ERROR(c, "Forbidden: Invalid signature")
|
||||
log.Println("Token verification failed")
|
||||
}
|
||||
}
|
||||
|
||||
func validateSignature(signature, token, timestamp, nonce string) bool {
|
||||
// 1. 将 token、timestamp、nonce 按字典序排序
|
||||
strs := []string{token, timestamp, nonce}
|
||||
sort.Strings(strs)
|
||||
|
||||
// 2. 拼接字符串
|
||||
joined := strings.Join(strs, "")
|
||||
|
||||
// 3. 计算 SHA1 哈希
|
||||
hash := sha1.New()
|
||||
hash.Write([]byte(joined))
|
||||
hashed := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// 4. 与 signature 比对
|
||||
return hashed == signature
|
||||
}
|
||||
|
||||
// 创建微信菜单
|
||||
func (h *WxGzhHandler) CreateMenu(c *gin.Context) {
|
||||
|
||||
resp.SUCCESS(c, "创建菜单成功")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package logger
|
||||
package log
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
+43
-29
@@ -14,22 +14,21 @@ import (
|
||||
"geekai/core/types"
|
||||
"geekai/handler"
|
||||
"geekai/handler/admin"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"geekai/service"
|
||||
"geekai/service/dalle"
|
||||
"geekai/service/image"
|
||||
"geekai/service/jimeng"
|
||||
"geekai/service/mj"
|
||||
"geekai/service/moderation"
|
||||
"geekai/service/oss"
|
||||
"geekai/service/payment"
|
||||
"geekai/service/sd"
|
||||
"geekai/service/ppt"
|
||||
"geekai/service/sms"
|
||||
"geekai/service/sora"
|
||||
"geekai/service/suno"
|
||||
"geekai/service/video"
|
||||
"geekai/store"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
@@ -43,7 +42,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
//go:embed res
|
||||
var xdbFS embed.FS
|
||||
@@ -89,7 +88,7 @@ func main() {
|
||||
fx.Provide(func() *types.AppConfig {
|
||||
config, err := core.LoadConfig(configFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
logger.Fatal(err)
|
||||
}
|
||||
config.Path = configFile
|
||||
return config
|
||||
@@ -136,16 +135,23 @@ func main() {
|
||||
fx.Provide(handler.NewSmsHandler),
|
||||
fx.Provide(handler.NewRedeemHandler),
|
||||
fx.Provide(handler.NewCaptchaHandler),
|
||||
fx.Provide(func(db *gorm.DB, userService *service.UserService, uploadManager *oss.UploaderManager) *ppt.PptService {
|
||||
return ppt.NewPptService(db, userService, uploadManager)
|
||||
}),
|
||||
fx.Provide(handler.NewPPTTaskHandler),
|
||||
fx.Invoke(func(ppt *ppt.PptService) {
|
||||
ppt.RecoverStaleProcessingTasks()
|
||||
}),
|
||||
fx.Provide(handler.NewMidJourneyHandler),
|
||||
fx.Provide(handler.NewChatModelHandler),
|
||||
fx.Provide(handler.NewSdJobHandler),
|
||||
fx.Provide(handler.NewPaymentHandler),
|
||||
fx.Provide(handler.NewOrderHandler),
|
||||
fx.Provide(handler.NewProductHandler),
|
||||
fx.Provide(handler.NewConfigHandler),
|
||||
fx.Provide(handler.NewPowerLogHandler),
|
||||
fx.Provide(handler.NewJimengHandler),
|
||||
|
||||
fx.Provide(service.NewWxGzhService),
|
||||
fx.Provide(handler.NewWxGzhHandler),
|
||||
fx.Provide(service.NewMigrationService),
|
||||
fx.Invoke(func(migrationService *service.MigrationService) {
|
||||
migrationService.StartMigrate()
|
||||
@@ -164,12 +170,15 @@ func main() {
|
||||
fx.Provide(admin.NewOrderHandler),
|
||||
fx.Provide(admin.NewPowerLogHandler),
|
||||
fx.Provide(admin.NewAdminJimengHandler),
|
||||
fx.Provide(admin.NewVideoHandler),
|
||||
fx.Provide(admin.NewSunoHandler),
|
||||
fx.Provide(admin.NewPPTHandler),
|
||||
|
||||
// 邮件服务
|
||||
fx.Provide(service.NewSmtpService),
|
||||
// Dalle 服务
|
||||
fx.Provide(dalle.NewService),
|
||||
fx.Invoke(func(s *dalle.Service) {
|
||||
// Image 服务
|
||||
fx.Provide(image.NewService),
|
||||
fx.Invoke(func(s *image.Service) {
|
||||
s.Run()
|
||||
s.DownloadImages()
|
||||
s.CheckTaskStatus()
|
||||
@@ -187,13 +196,6 @@ func main() {
|
||||
// Sora service
|
||||
fx.Provide(sora.NewSoraService),
|
||||
|
||||
// Stable Diffusion 机器人
|
||||
fx.Provide(sd.NewService),
|
||||
fx.Invoke(func(s *sd.Service, config *types.AppConfig) {
|
||||
s.Run()
|
||||
s.CheckTaskStatus()
|
||||
}),
|
||||
|
||||
fx.Provide(suno.NewService),
|
||||
fx.Invoke(func(s *suno.Service) {
|
||||
s.Run()
|
||||
@@ -219,6 +221,7 @@ func main() {
|
||||
// 创建短信服务
|
||||
fx.Provide(sms.NewAliYunSmsService),
|
||||
fx.Provide(sms.NewBaoSmsService),
|
||||
fx.Provide(sms.NewTencentSmsService),
|
||||
fx.Provide(sms.NewSmsManager),
|
||||
fx.Provide(service.NewCaptchaService),
|
||||
fx.Provide(service.NewWxLoginService),
|
||||
@@ -233,6 +236,7 @@ func main() {
|
||||
fx.Provide(oss.NewMiniOss),
|
||||
fx.Provide(oss.NewQiNiuOss),
|
||||
fx.Provide(oss.NewAliYunOss),
|
||||
fx.Provide(oss.NewTencentOss),
|
||||
fx.Provide(oss.NewUploaderManager),
|
||||
|
||||
// 用户服务
|
||||
@@ -267,15 +271,15 @@ func main() {
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.CaptchaHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.PPTTaskHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.RedeemHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.MidJourneyHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.SdJobHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.ConfigHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
@@ -366,8 +370,8 @@ func main() {
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.MarkMapHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Provide(handler.NewDallJobHandler),
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.DallJobHandler) {
|
||||
fx.Provide(handler.NewImageJobHandler),
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.ImageJobHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Provide(handler.NewSunoHandler),
|
||||
@@ -386,6 +390,15 @@ func main() {
|
||||
fx.Invoke(func(s *core.AppServer, h *admin.AdminJimengHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Invoke(func(s *core.AppServer, h *admin.VideoHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Invoke(func(s *core.AppServer, h *admin.SunoHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Invoke(func(s *core.AppServer, h *admin.PPTHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Provide(admin.NewChatAppTypeHandler),
|
||||
fx.Invoke(func(s *core.AppServer, h *admin.ChatAppTypeHandler) {
|
||||
h.RegisterRoutes()
|
||||
@@ -402,6 +415,11 @@ func main() {
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.PromptHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
|
||||
// 微信公众号路由
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.WxGzhHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Invoke(func(s *core.AppServer, db *gorm.DB) {
|
||||
go func() {
|
||||
err := s.Run(db)
|
||||
@@ -427,10 +445,6 @@ func main() {
|
||||
fx.Invoke(func(s *core.AppServer, h *admin.ImageHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Provide(admin.NewMediaHandler),
|
||||
fx.Invoke(func(s *core.AppServer, h *admin.MediaHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Provide(handler.NewRealtimeHandler),
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.RealtimeHandler) {
|
||||
h.RegisterRoutes()
|
||||
@@ -439,7 +453,7 @@ func main() {
|
||||
// 启动应用程序
|
||||
go func() {
|
||||
if err := app.Start(context.Background()); err != nil {
|
||||
log.Fatal(err)
|
||||
logger.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -452,7 +466,7 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := app.Stop(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
logger.Fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package dalle
|
||||
package image
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
@@ -10,12 +10,13 @@ package dalle
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
@@ -24,9 +25,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
// DALL-E 绘画服务
|
||||
// Image Generation Service
|
||||
|
||||
type Service struct {
|
||||
httpClient *req.Client
|
||||
@@ -40,50 +41,50 @@ func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Clien
|
||||
return &Service{
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
db: db,
|
||||
taskQueue: store.NewRedisQueue("DallE_Task_Queue", redisCli),
|
||||
taskQueue: store.NewRedisQueue("Image_Task_Queue", redisCli),
|
||||
uploadManager: manager,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
// PushTask push a new mj task in to task queue
|
||||
func (s *Service) PushTask(task types.DallTask) {
|
||||
logger.Infof("add a new DALL-E task to the task list: %+v", task)
|
||||
// PushTask push a new image task in to task queue
|
||||
func (s *Service) PushTask(task types.ImageTask) {
|
||||
logger.Infof("add a new Image generation task to the task list: %+v", task)
|
||||
if err := s.taskQueue.RPush(task); err != nil {
|
||||
logger.Errorf("push dall-e task to queue failed: %v", err)
|
||||
logger.Errorf("push image task to queue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的任务加载到队列
|
||||
var jobs []model.DallJob
|
||||
var jobs []model.ImageJob
|
||||
s.db.Where("progress", 0).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.DallTask
|
||||
err := utils.JsonDecode(v.TaskInfo, &task)
|
||||
var task types.ImageTask
|
||||
err := utils.JsonDecode(v.Params, &task)
|
||||
if err != nil {
|
||||
logger.Errorf("decode task info with error: %v", err)
|
||||
logger.Errorf("decode task params with error: %v", err)
|
||||
continue
|
||||
}
|
||||
task.Id = v.Id
|
||||
s.PushTask(task)
|
||||
}
|
||||
|
||||
logger.Info("Starting DALL-E job consumer...")
|
||||
logger.Info("Starting Image generation job consumer...")
|
||||
go func() {
|
||||
for {
|
||||
var task types.DallTask
|
||||
var task types.ImageTask
|
||||
err := s.taskQueue.LPop(&task)
|
||||
if err != nil {
|
||||
logger.Errorf("taking task with error: %v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("handle a new DALL-E task: %+v", task)
|
||||
logger.Infof("handle a new Image generation task: %+v", task)
|
||||
go func() {
|
||||
_, err = s.Image(task, false)
|
||||
if err != nil {
|
||||
logger.Errorf("error with image task: %v", err)
|
||||
s.db.Model(&model.DallJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
s.db.Model(&model.ImageJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress,
|
||||
"err_msg": err.Error(),
|
||||
})
|
||||
@@ -120,7 +121,7 @@ type ErrRes struct {
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (s *Service) Image(task types.DallTask, sync bool) (string, error) {
|
||||
func (s *Service) Image(task types.ImageTask, sync bool) (string, error) {
|
||||
logger.Debugf("绘画参数:%+v", task)
|
||||
|
||||
var chatModel model.ChatModel
|
||||
@@ -136,7 +137,7 @@ func (s *Service) Image(task types.DallTask, sync bool) (string, error) {
|
||||
if chatModel.KeyId > 0 {
|
||||
session = session.Where("id = ?", chatModel.KeyId)
|
||||
} else {
|
||||
session = session.Where("type = ?", "dalle")
|
||||
session = session.Where("type = ?", "image")
|
||||
}
|
||||
err := session.Order("last_used_at ASC").First(&apiKey).Error
|
||||
if err != nil {
|
||||
@@ -179,6 +180,11 @@ func (s *Service) Image(task types.DallTask, sync bool) (string, error) {
|
||||
return "", fmt.Errorf("error with send request, status: %s, %+v", r.Status, errRes.Error)
|
||||
}
|
||||
|
||||
if len(res.Data) == 0 && r.Body != nil {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return "", fmt.Errorf("%s", string(body))
|
||||
}
|
||||
|
||||
// update the api key last use time
|
||||
s.db.Model(&apiKey).UpdateColumn("last_used_at", time.Now().Unix())
|
||||
var imgURL string
|
||||
@@ -199,7 +205,7 @@ func (s *Service) Image(task types.DallTask, sync bool) (string, error) {
|
||||
}
|
||||
data["org_url"] = imgURL
|
||||
// update task progress
|
||||
err = s.db.Model(&model.DallJob{Id: task.Id}).UpdateColumns(data).Error
|
||||
err = s.db.Model(&model.ImageJob{Id: task.Id}).UpdateColumns(data).Error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("err with update database: %v", err)
|
||||
}
|
||||
@@ -214,10 +220,10 @@ func (s *Service) Image(task types.DallTask, sync bool) (string, error) {
|
||||
|
||||
func (s *Service) CheckTaskStatus() {
|
||||
go func() {
|
||||
logger.Info("Running DALL-E task status checking ...")
|
||||
logger.Info("Running Image generation task status checking ...")
|
||||
for {
|
||||
// 检查未完成任务进度
|
||||
var jobs []model.DallJob
|
||||
var jobs []model.ImageJob
|
||||
s.db.Where("progress < ?", 100).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
// 超时的任务标记为失败
|
||||
@@ -231,8 +237,8 @@ func (s *Service) CheckTaskStatus() {
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
s.db.Where("progress", service.FailTaskProgress).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
var task types.DallTask
|
||||
err := utils.JsonDecode(job.TaskInfo, &task)
|
||||
var task types.ImageTask
|
||||
err := utils.JsonDecode(job.Params, &task)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -254,7 +260,7 @@ func (s *Service) CheckTaskStatus() {
|
||||
|
||||
func (s *Service) DownloadImages() {
|
||||
go func() {
|
||||
var items []model.DallJob
|
||||
var items []model.ImageJob
|
||||
for {
|
||||
res := s.db.Where("img_url = ? AND progress = ?", "", 100).Find(&items)
|
||||
if res.Error != nil {
|
||||
@@ -291,7 +297,7 @@ func (s *Service) downloadImage(jobId uint, orgURL string) (string, error) {
|
||||
}
|
||||
|
||||
// update img_url
|
||||
res := s.db.Model(&model.DallJob{Id: jobId}).UpdateColumn("img_url", imgURL)
|
||||
res := s.db.Model(&model.ImageJob{Id: jobId}).UpdateColumn("img_url", imgURL)
|
||||
if res.Error != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store"
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
// Service 即梦服务(合并了消费者功能)
|
||||
type Service struct {
|
||||
@@ -249,8 +249,10 @@ func (s *Service) buildTaskRequest(req *types.JimengTaskRequest) (map[string]any
|
||||
|
||||
// duration 转成 frames
|
||||
if duration, ok := params["duration"]; ok {
|
||||
if secs, ok := duration.(int); ok {
|
||||
params["frames"] = secs*24 + 1
|
||||
if v, ok := duration.(int); ok {
|
||||
params["frames"] = v*24 + 1
|
||||
} else if v, ok := duration.(float64); ok {
|
||||
params["frames"] = int(v*24) + 1
|
||||
}
|
||||
delete(params, "duration")
|
||||
}
|
||||
+420
-123
@@ -8,17 +8,15 @@ package service
|
||||
// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -33,23 +31,21 @@ type MigrationService struct {
|
||||
db *gorm.DB
|
||||
redisClient *redis.Client
|
||||
appConfig *types.AppConfig
|
||||
levelDB *store.LevelDB
|
||||
}
|
||||
|
||||
func NewMigrationService(db *gorm.DB, redisClient *redis.Client, appConfig *types.AppConfig, levelDB *store.LevelDB) *MigrationService {
|
||||
func NewMigrationService(db *gorm.DB, redisClient *redis.Client, appConfig *types.AppConfig) *MigrationService {
|
||||
return &MigrationService{
|
||||
db: db,
|
||||
redisClient: redisClient,
|
||||
appConfig: appConfig,
|
||||
levelDB: levelDB,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MigrationService) StartMigrate() {
|
||||
// 表结构同步必须在对外服务前完成,避免缺列导致业务报错
|
||||
// 表结构迁移必须在业务服务启动前完成,避免新表和新列尚未创建就被后台任务查询。
|
||||
s.TableMigration()
|
||||
go func() {
|
||||
_ = s.MigrateConfig(s.appConfig)
|
||||
s.MigrateConfig(s.appConfig)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -126,163 +122,386 @@ func (s *MigrationService) MigrateConfigContent() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 永不删除的保护列(大小写不敏感)
|
||||
var protectedColumns = map[string]struct{}{
|
||||
"id": {},
|
||||
"created_at": {},
|
||||
"updated_at": {},
|
||||
}
|
||||
|
||||
// allModels 全部需要同步的数据表 model
|
||||
func allModels() []any {
|
||||
return []any{
|
||||
// fullTableMigration 第一步:全量表迁移,同步所有表结构(新增表、新增字段、字段类型)
|
||||
// 适用于首次安装或导入旧版数据库后同步 schema,AutoMigrate 会补齐缺失的表和列
|
||||
func (s *MigrationService) fullTableMigration() {
|
||||
logger.Info("执行全量表迁移(同步 schema)...")
|
||||
models := []any{
|
||||
&model.Config{},
|
||||
&model.AdminUser{},
|
||||
&model.ChatApp{},
|
||||
&model.ApiKey{},
|
||||
&model.AppType{},
|
||||
&model.ChatApp{},
|
||||
&model.ChatModel{},
|
||||
&model.User{},
|
||||
&model.ChatItem{},
|
||||
&model.ChatMessage{},
|
||||
&model.ChatModel{},
|
||||
&model.Config{},
|
||||
&model.DallJob{},
|
||||
&model.File{},
|
||||
&model.Order{},
|
||||
&model.Product{},
|
||||
&model.Function{},
|
||||
&model.Menu{},
|
||||
&model.InviteCode{},
|
||||
&model.InviteLog{},
|
||||
&model.JimengJob{},
|
||||
&model.Menu{},
|
||||
&model.MidJourneyJob{},
|
||||
&model.Moderation{},
|
||||
&model.Order{},
|
||||
&model.PowerLog{},
|
||||
&model.Product{},
|
||||
&model.Redeem{},
|
||||
&model.SdJob{},
|
||||
&model.SunoJob{},
|
||||
&model.User{},
|
||||
&model.PowerLog{},
|
||||
&model.File{},
|
||||
&model.UserLoginLog{},
|
||||
&model.MidJourneyJob{},
|
||||
&model.SunoJob{},
|
||||
&model.VideoJob{},
|
||||
&model.JimengJob{},
|
||||
&model.PPTJob{},
|
||||
&model.Moderation{},
|
||||
&model.ImageJob{},
|
||||
}
|
||||
}
|
||||
|
||||
// 数据表迁移:先处理字段重命名(保数据),再全量同步 schema
|
||||
func (s *MigrationService) TableMigration() {
|
||||
logger.Info("开始数据表迁移...")
|
||||
s.renameColumns()
|
||||
if err := s.SyncAllModels(); err != nil {
|
||||
logger.Errorf("同步数据表字段失败: %v", err)
|
||||
if err := s.db.AutoMigrate(models...); err != nil {
|
||||
logger.Errorf("全量表迁移失败: %v", err)
|
||||
return
|
||||
}
|
||||
logger.Info("数据表迁移完成")
|
||||
logger.Info("全量表迁移完成")
|
||||
}
|
||||
|
||||
// renameColumns 只处理「改名」场景:删旧加新会丢数据,必须先 Rename
|
||||
func (s *MigrationService) renameColumns() {
|
||||
m := s.db.Migrator()
|
||||
// fixTableConstraints 第一步之后:根据模型定义修复各表的主键、自增和关键索引
|
||||
// 主要解决初始化 SQL 中缺少 AUTO_INCREMENT 或 PRIMARY KEY 导致插入失败的问题
|
||||
func (s *MigrationService) fixTableConstraints() {
|
||||
logger.Info("开始修复各表的主键、自增属性和索引...")
|
||||
|
||||
if m.HasColumn(&model.JimengJob{}, "task_params") {
|
||||
_ = m.RenameColumn(&model.JimengJob{}, "task_params", "params")
|
||||
// 当前数据库名
|
||||
var dbName string
|
||||
if err := s.db.Raw("SELECT DATABASE()").Scan(&dbName).Error; err != nil {
|
||||
logger.Errorf("获取当前数据库名失败: %v", err)
|
||||
return
|
||||
}
|
||||
if m.HasColumn(&model.Order{}, "pay_type") {
|
||||
_ = m.RenameColumn(&model.Order{}, "pay_type", "channel")
|
||||
if dbName == "" {
|
||||
logger.Warn("当前连接未选择数据库,跳过约束修复")
|
||||
return
|
||||
}
|
||||
if m.HasColumn(&model.Config{}, "config_json") {
|
||||
_ = m.RenameColumn(&model.Config{}, "config_json", "value")
|
||||
}
|
||||
if m.HasColumn(&model.Config{}, "marker") {
|
||||
_ = m.RenameColumn(&model.Config{}, "marker", "name")
|
||||
}
|
||||
if m.HasIndex(&model.Config{}, "idx_chatgpt_configs_key") {
|
||||
_ = m.DropIndex(&model.Config{}, "idx_chatgpt_configs_key")
|
||||
}
|
||||
if m.HasIndex(&model.Config{}, "marker") {
|
||||
_ = m.DropIndex(&model.Config{}, "marker")
|
||||
}
|
||||
}
|
||||
|
||||
// SyncAllModels 按 model 定义同步所有数据表:缺列新建,多余列删除
|
||||
func (s *MigrationService) SyncAllModels() error {
|
||||
var firstErr error
|
||||
for _, m := range allModels() {
|
||||
if err := s.syncModel(m); err != nil {
|
||||
logger.Errorf("同步 model %T 失败: %v", m, err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
// === 修复所有包含 id 字段的表的主键 + 自增 ===
|
||||
type columnInfo struct {
|
||||
TableName string `gorm:"column:TABLE_NAME"`
|
||||
ColumnName string `gorm:"column:COLUMN_NAME"`
|
||||
ColumnKey string `gorm:"column:COLUMN_KEY"`
|
||||
Extra string `gorm:"column:EXTRA"`
|
||||
DataType string `gorm:"column:DATA_TYPE"`
|
||||
}
|
||||
|
||||
var idColumns []columnInfo
|
||||
if err := s.db.Raw(`
|
||||
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_KEY, EXTRA, DATA_TYPE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND COLUMN_NAME = 'id'
|
||||
`, dbName).Scan(&idColumns).Error; err != nil {
|
||||
logger.Errorf("查询各表 id 字段信息失败: %v", err)
|
||||
} else {
|
||||
for _, col := range idColumns {
|
||||
if col.ColumnName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查当前表是否已经存在主键
|
||||
type pkInfo struct {
|
||||
ColumnName string `gorm:"column:COLUMN_NAME"`
|
||||
}
|
||||
var pkColumns []pkInfo
|
||||
if err := s.db.Raw(`
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
|
||||
`, dbName, col.TableName).Scan(&pkColumns).Error; err != nil {
|
||||
logger.Errorf("查询表 %s 的主键信息失败: %v", col.TableName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
hasPK := len(pkColumns) > 0
|
||||
pkOnIdOnly := hasPK && len(pkColumns) == 1 && pkColumns[0].ColumnName == "id"
|
||||
|
||||
needAlter := false
|
||||
switch {
|
||||
case !hasPK:
|
||||
// 没有任何主键:允许将 id 设置为自增主键
|
||||
if col.ColumnKey != "PRI" || col.Extra == "" || !strings.Contains(col.Extra, "auto_increment") {
|
||||
needAlter = true
|
||||
}
|
||||
case pkOnIdOnly:
|
||||
// 只有 id 作为主键:只补充自增属性
|
||||
if col.Extra == "" || !strings.Contains(col.Extra, "auto_increment") {
|
||||
needAlter = true
|
||||
}
|
||||
default:
|
||||
// 已存在非 id 或组合主键:避免破坏原有主键,直接跳过
|
||||
logger.Infof("表 %s 已存在非 id 主键,跳过 id 自增主键修复", col.TableName)
|
||||
}
|
||||
if !needAlter {
|
||||
continue
|
||||
}
|
||||
|
||||
// 维持原来的数据类型,避免与历史 SQL 冲突
|
||||
dataType := col.DataType
|
||||
if dataType == "" {
|
||||
dataType = "int"
|
||||
}
|
||||
|
||||
alterSQL := fmt.Sprintf(
|
||||
"ALTER TABLE `%s` MODIFY COLUMN id %s NOT NULL AUTO_INCREMENT PRIMARY KEY",
|
||||
col.TableName,
|
||||
dataType,
|
||||
)
|
||||
if err := s.db.Exec(alterSQL).Error; err != nil {
|
||||
logger.Errorf("修复表 %s 的 id 自增主键失败: %v", col.TableName, err)
|
||||
} else {
|
||||
logger.Infof("已修复表 %s 的 id 为 AUTO_INCREMENT PRIMARY KEY", col.TableName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
|
||||
// === 为 geekai_users.username 补充唯一索引(根据模型 uniqueIndex 定义) ===
|
||||
var usernameUniqueCount int64
|
||||
err := s.db.Raw(`
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME = 'geekai_users'
|
||||
AND COLUMN_NAME = 'username'
|
||||
AND NON_UNIQUE = 0
|
||||
`, dbName).Scan(&usernameUniqueCount).Error
|
||||
if err != nil {
|
||||
logger.Errorf("检查 geekai_users.username 唯一索引失败: %v", err)
|
||||
} else if usernameUniqueCount == 0 {
|
||||
// 索引名尽量固定,避免重复创建
|
||||
if err := s.db.Exec("ALTER TABLE geekai_users ADD UNIQUE KEY idx_geekai_users_username (username)").Error; err != nil {
|
||||
logger.Errorf("创建 geekai_users.username 唯一索引失败: %v", err)
|
||||
} else {
|
||||
logger.Info("已为 geekai_users.username 创建唯一索引 idx_geekai_users_username")
|
||||
}
|
||||
} else {
|
||||
logger.Info("geekai_users.username 唯一索引已存在,跳过创建")
|
||||
}
|
||||
|
||||
logger.Info("关键表主键、自增和索引修复完成")
|
||||
}
|
||||
|
||||
func (s *MigrationService) syncModel(dst any) error {
|
||||
tableName := s.tableName(dst)
|
||||
if err := s.db.AutoMigrate(dst); err != nil {
|
||||
return fmt.Errorf("AutoMigrate %s: %w", tableName, err)
|
||||
// incrementalTableMigration 第二步:增量迁移,仅处理删除字段与数据迁移
|
||||
// AutoMigrate 不会删除列,故需在此显式 DropColumn;字段重命名需先拷贝数据再删除旧列
|
||||
func (s *MigrationService) incrementalTableMigration() {
|
||||
logger.Info("执行增量迁移(删除字段 + 数据迁移)...")
|
||||
|
||||
// ========== 字段重命名:全量迁移已添加新列,需将旧列数据拷贝到新列后删除旧列 ==========
|
||||
|
||||
// ChatApp(geekai_chat_roles): context_json -> system_prompt 历史数据迁移
|
||||
if s.db.Migrator().HasColumn(&model.ChatApp{}, "context_json") {
|
||||
// 将旧列 context_json 的值拷贝到 system_prompt(NULL 转为空字符串,保证 NOT NULL 约束)
|
||||
if err := s.db.Exec(`
|
||||
UPDATE geekai_chat_roles
|
||||
SET system_prompt = IFNULL(NULLIF(TRIM(COALESCE(context_json, '')), ''), '')
|
||||
`).Error; err != nil {
|
||||
logger.Errorf("迁移 geekai_chat_roles.context_json -> system_prompt 失败: %v", err)
|
||||
} else {
|
||||
if err := s.db.Migrator().DropColumn(&model.ChatApp{}, "context_json"); err != nil {
|
||||
logger.Errorf("删除 geekai_chat_roles.context_json 失败: %v", err)
|
||||
} else {
|
||||
logger.Info("geekai_chat_roles: context_json 已迁移至 system_prompt 并删除旧列")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.dropUnusedColumns(dst); err != nil {
|
||||
return fmt.Errorf("drop unused columns %s: %w", tableName, err)
|
||||
|
||||
// ChatApp: 将 user_id 为 NULL 的历史记录置为 0(系统内置)
|
||||
if s.db.Migrator().HasColumn(&model.ChatApp{}, "user_id") {
|
||||
if err := s.db.Exec(`UPDATE geekai_chat_roles SET user_id = 0 WHERE user_id IS NULL`).Error; err != nil {
|
||||
logger.Errorf("初始化 geekai_chat_roles.user_id 失败: %v", err)
|
||||
}
|
||||
}
|
||||
logger.Infof("已同步数据表: %s", tableName)
|
||||
return nil
|
||||
|
||||
// ChatApp(geekai_chat_roles): 删除 marker 列(应用仅通过 id 区分)
|
||||
var hasMarker int
|
||||
if s.db.Raw("SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'geekai_chat_roles' AND COLUMN_NAME = 'marker'").Scan(&hasMarker).Error == nil && hasMarker > 0 {
|
||||
// 先删除可能存在的唯一索引(不同版本 SQL 索引名不同)
|
||||
for _, idxName := range []string{"marker", "idx_chatgpt_chat_roles_marker", "idx_chatgpt_chat_roles_key", "idx_geekai_chat_roles_marker"} {
|
||||
_ = s.db.Exec(fmt.Sprintf("ALTER TABLE geekai_chat_roles DROP INDEX `%s`", idxName)).Error
|
||||
}
|
||||
if err := s.db.Exec("ALTER TABLE geekai_chat_roles DROP COLUMN marker").Error; err != nil {
|
||||
logger.Errorf("删除 geekai_chat_roles.marker 失败: %v", err)
|
||||
} else {
|
||||
logger.Info("geekai_chat_roles: 已删除 marker 列")
|
||||
}
|
||||
}
|
||||
|
||||
// Config: config_json -> value, marker -> name
|
||||
if s.db.Migrator().HasColumn(&model.Config{}, "config_json") {
|
||||
s.db.Exec("UPDATE geekai_configs SET `value` = config_json WHERE config_json IS NOT NULL AND config_json != ''")
|
||||
s.db.Migrator().DropColumn(&model.Config{}, "config_json")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.Config{}, "marker") {
|
||||
s.db.Exec("UPDATE geekai_configs SET `name` = marker WHERE marker IS NOT NULL AND marker != ''")
|
||||
s.db.Migrator().DropColumn(&model.Config{}, "marker")
|
||||
}
|
||||
if s.db.Migrator().HasIndex(&model.Config{}, "idx_chatgpt_configs_key") {
|
||||
s.db.Migrator().DropIndex(&model.Config{}, "idx_chatgpt_configs_key")
|
||||
}
|
||||
if s.db.Migrator().HasIndex(&model.Config{}, "marker") {
|
||||
s.db.Migrator().DropIndex(&model.Config{}, "marker")
|
||||
}
|
||||
|
||||
// Order: pay_type -> channel
|
||||
if s.db.Migrator().HasColumn(&model.Order{}, "pay_type") {
|
||||
s.db.Exec("UPDATE geekai_orders SET channel = pay_type WHERE pay_type IS NOT NULL AND pay_type != ''")
|
||||
s.db.Migrator().DropColumn(&model.Order{}, "pay_type")
|
||||
}
|
||||
|
||||
// JimengJob: task_params -> params
|
||||
if s.db.Migrator().HasColumn(&model.JimengJob{}, "task_params") {
|
||||
s.db.Exec("UPDATE geekai_jimeng_jobs SET params = task_params WHERE task_params IS NOT NULL AND task_params != ''")
|
||||
s.db.Migrator().DropColumn(&model.JimengJob{}, "task_params")
|
||||
}
|
||||
|
||||
// VideoJob: task_info -> params, raw_data -> output
|
||||
if s.db.Migrator().HasColumn(&model.VideoJob{}, "task_info") {
|
||||
s.db.Exec("UPDATE geekai_video_jobs SET params = task_info WHERE task_info IS NOT NULL AND task_info != ''")
|
||||
s.db.Migrator().DropColumn(&model.VideoJob{}, "task_info")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.VideoJob{}, "raw_data") {
|
||||
s.db.Exec("UPDATE geekai_video_jobs SET `output` = raw_data WHERE raw_data IS NOT NULL AND raw_data != ''")
|
||||
s.db.Migrator().DropColumn(&model.VideoJob{}, "raw_data")
|
||||
}
|
||||
|
||||
// SunoJob: task_info -> params, raw_data -> output
|
||||
if s.db.Migrator().HasColumn(&model.SunoJob{}, "task_info") {
|
||||
s.db.Exec("UPDATE geekai_suno_jobs SET params = task_info WHERE task_info IS NOT NULL AND task_info != ''")
|
||||
s.db.Migrator().DropColumn(&model.SunoJob{}, "task_info")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.SunoJob{}, "raw_data") {
|
||||
s.db.Exec("UPDATE geekai_suno_jobs SET `output` = raw_data WHERE raw_data IS NOT NULL AND raw_data != ''")
|
||||
s.db.Migrator().DropColumn(&model.SunoJob{}, "raw_data")
|
||||
}
|
||||
|
||||
// ========== 删除不再使用的字段 ==========
|
||||
|
||||
if s.db.Migrator().HasColumn(&model.Order{}, "deleted_at") {
|
||||
s.db.Migrator().DropColumn(&model.Order{}, "deleted_at")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.ChatItem{}, "deleted_at") {
|
||||
s.db.Migrator().DropColumn(&model.ChatItem{}, "deleted_at")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.ChatMessage{}, "deleted_at") {
|
||||
s.db.Migrator().DropColumn(&model.ChatMessage{}, "deleted_at")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.User{}, "chat_config") {
|
||||
s.db.Migrator().DropColumn(&model.User{}, "chat_config")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.ChatModel{}, "category") {
|
||||
s.db.Migrator().DropColumn(&model.ChatModel{}, "category")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.ChatModel{}, "description") {
|
||||
s.db.Migrator().DropColumn(&model.ChatModel{}, "description")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.Product{}, "discount") {
|
||||
s.db.Migrator().DropColumn(&model.Product{}, "discount")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.Product{}, "days") {
|
||||
s.db.Migrator().DropColumn(&model.Product{}, "days")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.Product{}, "app_url") {
|
||||
s.db.Migrator().DropColumn(&model.Product{}, "app_url")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.Product{}, "url") {
|
||||
s.db.Migrator().DropColumn(&model.Product{}, "url")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.VideoJob{}, "water_url") {
|
||||
s.db.Migrator().DropColumn(&model.VideoJob{}, "water_url")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.VideoJob{}, "cover_url") {
|
||||
s.db.Migrator().DropColumn(&model.VideoJob{}, "cover_url")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.VideoJob{}, "prompt_ext") {
|
||||
s.db.Migrator().DropColumn(&model.VideoJob{}, "prompt_ext")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.SunoJob{}, "instrumental") {
|
||||
s.db.Migrator().DropColumn(&model.SunoJob{}, "instrumental")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.SunoJob{}, "tags") {
|
||||
s.db.Migrator().DropColumn(&model.SunoJob{}, "tags")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.SunoJob{}, "extend_secs") {
|
||||
s.db.Migrator().DropColumn(&model.SunoJob{}, "extend_secs")
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.SunoJob{}, "model_name") {
|
||||
s.db.Migrator().DropColumn(&model.SunoJob{}, "model_name")
|
||||
}
|
||||
|
||||
// ========== 数据迁移:根据业务逻辑更新现有数据 ==========
|
||||
|
||||
// video_job: 根据 progress 填充 status
|
||||
if s.db.Migrator().HasColumn(&model.VideoJob{}, "status") {
|
||||
s.db.Exec(`UPDATE geekai_video_jobs SET status = CASE
|
||||
WHEN progress < 100 THEN 'in_progress'
|
||||
WHEN progress = 100 THEN 'success'
|
||||
WHEN progress = 101 THEN 'failed'
|
||||
WHEN progress = 102 THEN 'downloading'
|
||||
ELSE 'pending'
|
||||
END WHERE status = '' OR status IS NULL`)
|
||||
}
|
||||
|
||||
// suno_job: 从 output 提取 tags/model_name 填入 params
|
||||
s.migrateSunoJobData()
|
||||
|
||||
logger.Info("增量迁移完成")
|
||||
}
|
||||
|
||||
func (s *MigrationService) dropUnusedColumns(dst any) error {
|
||||
dbCols, err := s.db.Migrator().ColumnTypes(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
// TableMigration 数据表迁移入口:先全量同步 schema,再增量删除字段并迁移数据
|
||||
func (s *MigrationService) TableMigration() {
|
||||
s.fullTableMigration()
|
||||
s.fixTableConstraints()
|
||||
s.incrementalTableMigration()
|
||||
s.migrateChatAppSystemPromptFromJSON()
|
||||
}
|
||||
|
||||
// migrateChatAppSystemPromptFromJSON 将智能体 system_prompt 字段中历史 JSON 数组
|
||||
// 解析后取出 role 为 system 的 content,覆盖回 system_prompt(纯文本)
|
||||
func (s *MigrationService) migrateChatAppSystemPromptFromJSON() {
|
||||
key := "migrate:chat_app_system_prompt_json"
|
||||
if s.redisClient.Get(context.Background(), key).Val() == "1" {
|
||||
logger.Info("ChatApp system_prompt JSON 已迁移,跳过")
|
||||
return
|
||||
}
|
||||
modelCols, err := s.modelColumnNames(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
logger.Info("开始迁移智能体 system_prompt 历史 JSON 数据...")
|
||||
|
||||
var apps []model.ChatApp
|
||||
if err := s.db.Find(&apps).Error; err != nil {
|
||||
logger.Errorf("查询 ChatApp 失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, col := range dbCols {
|
||||
name := col.Name()
|
||||
if s.isProtectedColumn(name) {
|
||||
updated := 0
|
||||
for i := range apps {
|
||||
raw := strings.TrimSpace(apps[i].SystemPrompt)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := modelCols[strings.ToLower(name)]; ok {
|
||||
if len(raw) < 2 || raw[0] != '[' {
|
||||
continue
|
||||
}
|
||||
logger.Infof("删除多余字段: %s.%s", s.tableName(dst), name)
|
||||
if err := s.db.Migrator().DropColumn(dst, name); err != nil {
|
||||
return fmt.Errorf("DropColumn %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MigrationService) modelColumnNames(dst any) (map[string]struct{}, error) {
|
||||
parsed, err := schema.Parse(dst, &schemaCache, s.db.Config.NamingStrategy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols := make(map[string]struct{}, len(parsed.Fields))
|
||||
for _, field := range parsed.Fields {
|
||||
if field.DBName == "" || field.IgnoreMigration {
|
||||
var messages []types.Message
|
||||
if err := json.Unmarshal([]byte(raw), &messages); err != nil {
|
||||
continue
|
||||
}
|
||||
cols[strings.ToLower(field.DBName)] = struct{}{}
|
||||
var systemContent string
|
||||
for _, m := range messages {
|
||||
if strings.ToLower(strings.TrimSpace(m.Role)) == "system" && m.Content != "" {
|
||||
systemContent = m.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := s.db.Model(&model.ChatApp{}).Where("id = ?", apps[i].Id).Update("system_prompt", systemContent).Error; err != nil {
|
||||
logger.Warnf("更新 ChatApp id=%d system_prompt 失败: %v", apps[i].Id, err)
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
func (s *MigrationService) isProtectedColumn(name string) bool {
|
||||
_, ok := protectedColumns[strings.ToLower(name)]
|
||||
return ok
|
||||
logger.Infof("智能体 system_prompt JSON 迁移完成,共更新 %d 条", updated)
|
||||
s.redisClient.Set(context.Background(), key, "1", 0)
|
||||
}
|
||||
|
||||
func (s *MigrationService) tableName(dst any) string {
|
||||
stmt := &gorm.Statement{DB: s.db}
|
||||
if err := stmt.Parse(dst); err != nil {
|
||||
return fmt.Sprintf("%T", dst)
|
||||
}
|
||||
return stmt.Schema.Table
|
||||
}
|
||||
|
||||
// schema.Parse 进程内复用的 schema cache
|
||||
var schemaCache sync.Map
|
||||
|
||||
// 迁移配置数据
|
||||
func (s *MigrationService) MigrateConfig(config *types.AppConfig) error {
|
||||
|
||||
@@ -374,6 +593,14 @@ func (s *MigrationService) migrateCommunicationConfig(config *types.AppConfig) e
|
||||
"sign": config.SMS.Bao.Sign,
|
||||
"code_template": config.SMS.Bao.CodeTemplate,
|
||||
},
|
||||
"tencent": map[string]any{
|
||||
"secret_id": config.SMS.Tencent.SecretId,
|
||||
"secret_key": config.SMS.Tencent.SecretKey,
|
||||
"sms_sdk_app_id": config.SMS.Tencent.SmsSdkAppId,
|
||||
"sign": config.SMS.Tencent.Sign,
|
||||
"code_temp_id": config.SMS.Tencent.CodeTempId,
|
||||
"region": config.SMS.Tencent.Region,
|
||||
},
|
||||
}
|
||||
return s.saveConfig(types.ConfigKeySms, smsConfig)
|
||||
}
|
||||
@@ -406,3 +633,73 @@ func (s *MigrationService) saveConfig(key string, config any) error {
|
||||
logger.Infof("成功迁移配置 %s", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateSunoJobData 合并 suno_job 数据:从 Output 原始数据中解析出 tags 和 model_name 填入 params 字段
|
||||
func (s *MigrationService) migrateSunoJobData() {
|
||||
key := "migrate:suno_job_data"
|
||||
if s.redisClient.Get(context.Background(), key).Val() == "1" {
|
||||
logger.Info("SunoJob 数据已合并,跳过迁移")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("开始合并 SunoJob 数据...")
|
||||
|
||||
// 查询所有有 output 数据的记录
|
||||
var jobs []model.SunoJob
|
||||
if err := s.db.Where("output != ? AND output != ''", "").Find(&jobs).Error; err != nil {
|
||||
logger.Errorf("查询 SunoJob 数据失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedCount := 0
|
||||
for _, job := range jobs {
|
||||
if job.Output == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 Output JSON 数据
|
||||
var outputData struct {
|
||||
Metadata struct {
|
||||
Tags string `json:"tags"`
|
||||
} `json:"metadata"`
|
||||
ModelName string `json:"model_name"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(job.Output), &outputData); err != nil {
|
||||
logger.Warnf("解析 Output 数据失败 (ID: %d): %v", job.Id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否需要更新 params
|
||||
needUpdate := false
|
||||
params := job.Params
|
||||
|
||||
// 如果 params 中的 tags 为空,但 output 中有 tags,则更新
|
||||
if params.Tags == "" && outputData.Metadata.Tags != "" {
|
||||
params.Tags = outputData.Metadata.Tags
|
||||
// 修复 tags 字段过长导致更新失败
|
||||
if len(params.Tags) > 255 {
|
||||
params.Tags = params.Tags[:255]
|
||||
}
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
// 如果 params 中的 model 为空,但 output 中有 model_name,则更新
|
||||
if params.Model == "" && outputData.ModelName != "" {
|
||||
params.Model = outputData.ModelName
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
// 如果需要更新,则保存
|
||||
if needUpdate {
|
||||
if err := s.db.Model(&model.SunoJob{}).Where("id = ?", job.Id).Update("params", params).Error; err != nil {
|
||||
logger.Errorf("更新 SunoJob 数据失败 (ID: %d): %v", job.Id, err)
|
||||
continue
|
||||
}
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("SunoJob 数据合并完成,共更新 %d 条记录", updatedCount)
|
||||
s.redisClient.Set(context.Background(), key, "1", 0)
|
||||
}
|
||||
|
||||
@@ -12,17 +12,20 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var logger = log.GetLogger()
|
||||
|
||||
// Client MidJourney client
|
||||
type Client struct {
|
||||
client *req.Client
|
||||
@@ -73,8 +76,6 @@ type QueryRes struct {
|
||||
SubmitTime int `json:"submitTime"`
|
||||
}
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
|
||||
func NewClient(db *gorm.DB) *Client {
|
||||
return &Client{
|
||||
client: req.C().SetTimeout(time.Minute).SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"),
|
||||
@@ -182,6 +183,28 @@ func (c *Client) Variation(task types.MjTask) (ImageRes, error) {
|
||||
return c.doRequest(body, apiPath, task.ChannelId)
|
||||
}
|
||||
|
||||
// Modal 提交局部重绘(inpaint)/ ZOOM,请求体 taskId 必填(提交成功返回的 taskId,不是查询结果里的 messageId),prompt、maskBase64 可选
|
||||
func (c *Client) Modal(task types.MjTask) (ImageRes, error) {
|
||||
apiPath := fmt.Sprintf("mj-%s/mj/submit/modal", task.Mode)
|
||||
taskId := task.TaskId
|
||||
if taskId == "" {
|
||||
taskId = task.MessageId
|
||||
}
|
||||
if taskId == "" {
|
||||
return ImageRes{}, fmt.Errorf("modal 任务缺少原图 taskId(提交成功返回的 ID)")
|
||||
}
|
||||
body := map[string]any{
|
||||
"taskId": taskId,
|
||||
}
|
||||
if task.Prompt != "" {
|
||||
body["prompt"] = task.Prompt
|
||||
}
|
||||
if task.MaskBase64 != "" {
|
||||
body["maskBase64"] = task.MaskBase64
|
||||
}
|
||||
return c.doRequest(body, apiPath, task.ChannelId)
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(body interface{}, apiPath string, channel string) (ImageRes, error) {
|
||||
var res ImageRes
|
||||
session := c.db.Session(&gorm.Session{}).Where("type", "mj").Where("enabled", true)
|
||||
|
||||
@@ -97,6 +97,9 @@ func (s *Service) Run() {
|
||||
case types.TaskSwapFace:
|
||||
res, err = s.client.SwapFace(task)
|
||||
break
|
||||
case types.TaskModal:
|
||||
res, err = s.client.Modal(task)
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil || (res.Code != 1 && res.Code != 22) {
|
||||
|
||||
@@ -2,8 +2,6 @@ package moderation
|
||||
|
||||
import (
|
||||
"geekai/core/types"
|
||||
|
||||
logger2 "geekai/logger"
|
||||
)
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
@@ -13,8 +11,6 @@ import (
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
|
||||
type Service interface {
|
||||
Moderate(text string) (types.ModerationResult, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package oss
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tencentyun/cos-go-sdk-v5"
|
||||
)
|
||||
|
||||
type TencentOss struct {
|
||||
config types.TencentOssConfig
|
||||
client *cos.Client
|
||||
proxyURL string
|
||||
}
|
||||
|
||||
func NewTencentOss(sysConfig *types.SystemConfig, appConfig *types.AppConfig) (*TencentOss, error) {
|
||||
s := &TencentOss{
|
||||
proxyURL: appConfig.ProxyURL,
|
||||
}
|
||||
err := s.UpdateConfig(sysConfig.OSS.Tencent)
|
||||
if err != nil {
|
||||
logger.Warnf("腾讯云COS初始化失败: %v", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *TencentOss) UpdateConfig(config types.TencentOssConfig) error {
|
||||
if config.Bucket == "" || config.Region == "" || config.SecretId == "" || config.SecretKey == "" {
|
||||
// 配置不完整时不初始化客户端
|
||||
s.config = config
|
||||
return nil
|
||||
}
|
||||
|
||||
// 构建 COS 客户端 URL
|
||||
cosURL := fmt.Sprintf("https://%s.cos.%s.myqcloud.com", config.Bucket, config.Region)
|
||||
u, err := url.Parse(cosURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing COS URL: %v", err)
|
||||
}
|
||||
|
||||
// 创建 COS 客户端
|
||||
b := &cos.BaseURL{BucketURL: u}
|
||||
client := cos.NewClient(b, &http.Client{
|
||||
Transport: &cos.AuthorizationTransport{
|
||||
SecretID: config.SecretId,
|
||||
SecretKey: config.SecretKey,
|
||||
},
|
||||
})
|
||||
|
||||
s.client = client
|
||||
s.config = config
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s TencentOss) PutFile(ctx *gin.Context, name string) (File, error) {
|
||||
// 解析表单
|
||||
file, err := ctx.FormFile(name)
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
// 打开上传文件
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
fileExt := filepath.Ext(file.Filename)
|
||||
objectKey := fmt.Sprintf("%d%s", time.Now().UnixMicro(), fileExt)
|
||||
// 上传文件
|
||||
_, err = s.client.Object.Put(ctx, objectKey, src, nil)
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
|
||||
// 生成文件 URL
|
||||
fileURL := s.generateURL(objectKey)
|
||||
|
||||
return File{
|
||||
Name: file.Filename,
|
||||
ObjKey: objectKey,
|
||||
URL: fileURL,
|
||||
Ext: fileExt,
|
||||
Size: file.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s TencentOss) PutUrlFile(fileURL string, ext string, useProxy bool) (string, error) {
|
||||
var fileData []byte
|
||||
var err error
|
||||
if useProxy {
|
||||
fileData, err = utils.DownloadImage(fileURL, s.proxyURL)
|
||||
} else {
|
||||
fileData, err = utils.DownloadImage(fileURL, "")
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error with download image: %v", err)
|
||||
}
|
||||
parse, err := url.Parse(fileURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error with parse image URL: %v", err)
|
||||
}
|
||||
if ext == "" {
|
||||
ext = filepath.Ext(parse.Path)
|
||||
}
|
||||
objectKey := fmt.Sprintf("%d%s", time.Now().UnixMicro(), ext)
|
||||
// 上传文件字节数据
|
||||
_, err = s.client.Object.Put(context.Background(), objectKey, bytes.NewReader(fileData), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.generateURL(objectKey), nil
|
||||
}
|
||||
|
||||
func (s TencentOss) PutBase64(base64Img string) (string, error) {
|
||||
imageData, err := base64.StdEncoding.DecodeString(base64Img)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error decoding base64:%v", err)
|
||||
}
|
||||
objectKey := fmt.Sprintf("%d.png", time.Now().UnixMicro())
|
||||
// 上传文件字节数据
|
||||
_, err = s.client.Object.Put(context.Background(), objectKey, bytes.NewReader(imageData), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.generateURL(objectKey), nil
|
||||
}
|
||||
|
||||
func (s TencentOss) Delete(fileURL string) error {
|
||||
var objectKey string
|
||||
if strings.HasPrefix(fileURL, "http") {
|
||||
objectKey = filepath.Base(fileURL)
|
||||
} else {
|
||||
objectKey = fileURL
|
||||
}
|
||||
_, err := s.client.Object.Delete(context.Background(), objectKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// generateURL 生成文件访问 URL
|
||||
func (s TencentOss) generateURL(objectKey string) string {
|
||||
if s.config.Domain != "" {
|
||||
// 使用自定义域名
|
||||
return fmt.Sprintf("%s/%s", strings.TrimSuffix(s.config.Domain, "/"), objectKey)
|
||||
}
|
||||
// 使用 COS 默认域名
|
||||
return fmt.Sprintf("https://%s.cos.%s.myqcloud.com/%s", s.config.Bucket, s.config.Region, objectKey)
|
||||
}
|
||||
|
||||
var _ Uploader = TencentOss{}
|
||||
@@ -13,6 +13,7 @@ const Local = "local"
|
||||
const Minio = "minio"
|
||||
const QiNiu = "qiniu"
|
||||
const AliYun = "aliyun"
|
||||
const Tencent = "tencent"
|
||||
|
||||
type File struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -8,32 +8,41 @@ package oss
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"strings"
|
||||
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
// 默认缩略图模板(本地存储格式)
|
||||
const DefaultThumbTemplate = "?imageView2/4/w/{width}/h/{height}/q/75"
|
||||
|
||||
type UploaderManager struct {
|
||||
local *LocalStorage
|
||||
aliyun *AliYunOss
|
||||
mini *MiniOss
|
||||
qiniu *QiNiuOss
|
||||
active string
|
||||
local *LocalStorage
|
||||
aliyun *AliYunOss
|
||||
mini *MiniOss
|
||||
qiniu *QiNiuOss
|
||||
tencent *TencentOss
|
||||
active string
|
||||
ossConfig types.OSSConfig // 保存当前OSS配置
|
||||
}
|
||||
|
||||
func NewUploaderManager(sysConfig *types.SystemConfig, local *LocalStorage, aliyun *AliYunOss, mini *MiniOss, qiniu *QiNiuOss) (*UploaderManager, error) {
|
||||
func NewUploaderManager(sysConfig *types.SystemConfig, local *LocalStorage, aliyun *AliYunOss, mini *MiniOss, qiniu *QiNiuOss, tencent *TencentOss) (*UploaderManager, error) {
|
||||
if sysConfig.OSS.Active == "" {
|
||||
sysConfig.OSS.Active = Local
|
||||
}
|
||||
|
||||
return &UploaderManager{
|
||||
active: sysConfig.OSS.Active,
|
||||
local: local,
|
||||
aliyun: aliyun,
|
||||
mini: mini,
|
||||
qiniu: qiniu,
|
||||
active: sysConfig.OSS.Active,
|
||||
local: local,
|
||||
aliyun: aliyun,
|
||||
mini: mini,
|
||||
qiniu: qiniu,
|
||||
tencent: tencent,
|
||||
ossConfig: sysConfig.OSS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -47,6 +56,8 @@ func (m *UploaderManager) GetUploadHandler() Uploader {
|
||||
return m.mini
|
||||
case QiNiu:
|
||||
return m.qiniu
|
||||
case Tencent:
|
||||
return m.tencent
|
||||
}
|
||||
return m.local
|
||||
}
|
||||
@@ -61,6 +72,71 @@ func (m *UploaderManager) UpdateConfig(config types.OSSConfig) {
|
||||
m.mini.UpdateConfig(config.Minio)
|
||||
case QiNiu:
|
||||
m.qiniu.UpdateConfig(config.QiNiu)
|
||||
case Tencent:
|
||||
m.tencent.UpdateConfig(config.Tencent)
|
||||
}
|
||||
m.active = config.Active
|
||||
m.ossConfig = config
|
||||
}
|
||||
|
||||
// GetThumbURL 根据原始图片URL和尺寸生成缩略图URL
|
||||
// 如果模板为空,使用默认模板(本地存储格式)
|
||||
// 如果明确设置为空字符串(表示不支持缩略图),返回原图URL
|
||||
func (m *UploaderManager) GetThumbURL(originalURL string, width, height int) string {
|
||||
var template string
|
||||
|
||||
// 根据当前激活的存储引擎获取对应的模板
|
||||
switch m.active {
|
||||
case Local:
|
||||
template = m.ossConfig.Local.ThumbTemplate
|
||||
case AliYun:
|
||||
template = m.ossConfig.AliYun.ThumbTemplate
|
||||
case Minio:
|
||||
template = m.ossConfig.Minio.ThumbTemplate
|
||||
case QiNiu:
|
||||
template = m.ossConfig.QiNiu.ThumbTemplate
|
||||
case Tencent:
|
||||
template = m.ossConfig.Tencent.ThumbTemplate
|
||||
default:
|
||||
template = m.ossConfig.Local.ThumbTemplate
|
||||
}
|
||||
|
||||
// 如果模板为空,使用默认模板(兼容旧配置)
|
||||
if template == "" {
|
||||
template = DefaultThumbTemplate
|
||||
}
|
||||
|
||||
// 替换变量
|
||||
thumbURL := strings.ReplaceAll(template, "{width}", fmt.Sprintf("%d", width))
|
||||
thumbURL = strings.ReplaceAll(thumbURL, "{height}", fmt.Sprintf("%d", height))
|
||||
|
||||
// 拼接原始URL和缩略图参数
|
||||
return originalURL + thumbURL
|
||||
}
|
||||
|
||||
// GetThumbTemplate 获取当前存储引擎的缩略图模板
|
||||
func (m *UploaderManager) GetThumbTemplate() string {
|
||||
var template string
|
||||
|
||||
switch m.active {
|
||||
case Local:
|
||||
template = m.ossConfig.Local.ThumbTemplate
|
||||
case AliYun:
|
||||
template = m.ossConfig.AliYun.ThumbTemplate
|
||||
case Minio:
|
||||
template = m.ossConfig.Minio.ThumbTemplate
|
||||
case QiNiu:
|
||||
template = m.ossConfig.QiNiu.ThumbTemplate
|
||||
case Tencent:
|
||||
template = m.ossConfig.Tencent.ThumbTemplate
|
||||
default:
|
||||
template = m.ossConfig.Local.ThumbTemplate
|
||||
}
|
||||
|
||||
// 如果模板为空,返回默认模板
|
||||
if template == "" {
|
||||
return DefaultThumbTemplate
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -24,7 +24,7 @@ type AlipayService struct {
|
||||
config *types.AlipayConfig
|
||||
}
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
func NewAlipayService(sysConfig *types.SystemConfig) (*AlipayService, error) {
|
||||
config := sysConfig.Payment.Alipay
|
||||
|
||||
@@ -9,6 +9,7 @@ package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/utils"
|
||||
@@ -88,7 +89,18 @@ func (s *WxPayService) Pay(params PayRequest) (string, error) {
|
||||
if wxRsp.Code != wechat.Success {
|
||||
return "", fmt.Errorf("error status with generating pay url: %v", wxRsp.Error)
|
||||
}
|
||||
return wxRsp.Response.PrepayId, nil
|
||||
// 签名
|
||||
payParams, err := s.client.PaySignOfJSAPI(s.config.AppId, wxRsp.Response.PrepayId)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error with generating jsapi pay sign: %v", err)
|
||||
}
|
||||
payParamsBytes, err := json.Marshal(payParams)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error with marshaling pay params: %v", err)
|
||||
}
|
||||
|
||||
return string(payParamsBytes), nil
|
||||
} else if params.Device == "pc" {
|
||||
wxRsp, err := s.client.V3TransactionNative(context.Background(), bm)
|
||||
if err != nil {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,404 @@
|
||||
package ppt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/jung-kurt/gofpdf/v2"
|
||||
"github.com/ktye/pptx"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
//go:embed embed/minimal.pptx
|
||||
var minimalPptxTemplate []byte
|
||||
|
||||
const (
|
||||
exportHTTPTimeout = 60 * time.Second
|
||||
// 必须与 embed/minimal.pptx 中 p:sldSz 一致(当前模板为 4:3:10\"×7.5\")
|
||||
slideEmuW pptx.Dimension = 9144000
|
||||
slideEmuH pptx.Dimension = 6858000
|
||||
)
|
||||
|
||||
// ExportFormat 导出类型
|
||||
type ExportFormat string
|
||||
|
||||
const (
|
||||
ExportFormatPDF ExportFormat = "pdf"
|
||||
ExportFormatPPTX ExportFormat = "pptx"
|
||||
)
|
||||
|
||||
// ExportMimeType 返回 Content-Type
|
||||
func ExportMimeType(f ExportFormat) string {
|
||||
switch f {
|
||||
case ExportFormatPDF:
|
||||
return "application/pdf"
|
||||
case ExportFormatPPTX:
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ExportFileExt 返回文件扩展名(含点)
|
||||
func ExportFileExt(f ExportFormat) string {
|
||||
switch f {
|
||||
case ExportFormatPDF:
|
||||
return ".pdf"
|
||||
case ExportFormatPPTX:
|
||||
return ".pptx"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ParseExportFormat 解析 query format
|
||||
func ParseExportFormat(s string) (ExportFormat, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "pdf":
|
||||
return ExportFormatPDF, true
|
||||
case "pptx", "ppt":
|
||||
return ExportFormatPPTX, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// SanitizeExportBaseName 用于下载文件名的主体(不含扩展名)
|
||||
func SanitizeExportBaseName(title, taskID string) string {
|
||||
s := strings.TrimSpace(title)
|
||||
repl := strings.NewReplacer(
|
||||
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_",
|
||||
)
|
||||
s = repl.Replace(s)
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if r == unicode.ReplacementChar || r < 32 {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
s = strings.TrimSpace(b.String())
|
||||
if s == "" {
|
||||
s = strings.TrimSpace(taskID)
|
||||
}
|
||||
if len([]rune(s)) > 120 {
|
||||
rs := []rune(s)
|
||||
s = string(rs[:120])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ContentDispositionAttachment RFC 5987,兼容旧客户端
|
||||
func ContentDispositionAttachment(filename string) string {
|
||||
ascii := filename
|
||||
for _, r := range filename {
|
||||
if r > 127 || r == '"' || r == '\\' {
|
||||
ascii = "export" + strings.ToLower(filepath.Ext(filename))
|
||||
if ascii == "export" {
|
||||
ascii = "export.bin"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, ascii, url.PathEscape(filename))
|
||||
}
|
||||
|
||||
// mapLocalUploadFile 将站点相对路径或完整 BaseURL 前缀映射为本地文件路径(local OSS)
|
||||
func mapLocalUploadFile(raw string, local types.LocalStorageConfig) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || local.BasePath == "" {
|
||||
return "", false
|
||||
}
|
||||
bp := filepath.Clean(local.BasePath)
|
||||
bu := strings.TrimSuffix(strings.TrimSpace(local.BaseURL), "/")
|
||||
if bu != "" && strings.HasPrefix(raw, bu) {
|
||||
suffix := strings.TrimPrefix(strings.TrimPrefix(raw, bu), "/")
|
||||
return filepath.Join(bp, suffix), true
|
||||
}
|
||||
if strings.HasPrefix(raw, local.BaseURL) {
|
||||
return filepath.Join(bp, strings.TrimPrefix(raw, local.BaseURL)), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func originFromBaseURL(baseURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
func defaultOriginFromListen(listen string) string {
|
||||
listen = strings.TrimSpace(listen)
|
||||
if listen == "" {
|
||||
return ""
|
||||
}
|
||||
host, port, err := net.SplitHostPort(listen)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(listen, ":") {
|
||||
return "http://127.0.0.1" + listen
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if host == "0.0.0.0" || host == "::" || host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return "http://" + net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// resolveAbsoluteImageURL 将可能为相对路径的地址转为可 HTTP 访问的绝对 URL
|
||||
func resolveAbsoluteImageURL(raw string, local types.LocalStorageConfig, app *types.AppConfig) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return raw
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return raw
|
||||
}
|
||||
if strings.HasPrefix(raw, "//") {
|
||||
return "https:" + raw
|
||||
}
|
||||
origin := originFromBaseURL(local.BaseURL)
|
||||
if origin == "" && app != nil {
|
||||
origin = originFromBaseURL(app.StaticUrl)
|
||||
}
|
||||
if origin == "" && app != nil {
|
||||
origin = defaultOriginFromListen(app.Listen)
|
||||
}
|
||||
if origin == "" {
|
||||
return raw
|
||||
}
|
||||
if strings.HasPrefix(raw, "/") {
|
||||
return strings.TrimSuffix(origin, "/") + raw
|
||||
}
|
||||
return strings.TrimSuffix(origin, "/") + "/" + raw
|
||||
}
|
||||
|
||||
// BuildExportBytes 按幻灯片顺序拉取图片并生成 PDF 或 PPTX(oss/app 用于解析相对路径图片 URL)
|
||||
func BuildExportBytes(ctx context.Context, slides []SlideData, format ExportFormat, oss types.OSSConfig, app *types.AppConfig) ([]byte, error) {
|
||||
raws, err := fetchSlideImages(ctx, slides, oss, app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raws) == 0 {
|
||||
return nil, fmt.Errorf("没有可导出的幻灯片图片")
|
||||
}
|
||||
switch format {
|
||||
case ExportFormatPDF:
|
||||
return buildPDF(raws)
|
||||
case ExportFormatPPTX:
|
||||
return buildPPTX(raws)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的导出格式")
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSlideImages(ctx context.Context, slides []SlideData, oss types.OSSConfig, app *types.AppConfig) ([][]byte, error) {
|
||||
cp := append([]SlideData(nil), slides...)
|
||||
sort.Slice(cp, func(i, j int) bool { return cp[i].SlideIndex < cp[j].SlideIndex })
|
||||
|
||||
client := &http.Client{Timeout: exportHTTPTimeout}
|
||||
var out [][]byte
|
||||
for _, s := range cp {
|
||||
u := strings.TrimSpace(s.ImageURL)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var body []byte
|
||||
if oss.Active == "local" {
|
||||
if fp, ok := mapLocalUploadFile(u, oss.Local); ok {
|
||||
b, err := os.ReadFile(fp)
|
||||
if err == nil {
|
||||
if _, err := decodeImageBytes(b); err == nil {
|
||||
body = b
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(body) == 0 {
|
||||
absURL := resolveAbsoluteImageURL(u, oss.Local, app)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, absURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("幻灯片 %d: %w", s.SlideIndex, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("幻灯片 %d 下载失败: %w", s.SlideIndex, err)
|
||||
}
|
||||
body, err = io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("幻灯片 %d 读取失败: %w", s.SlideIndex, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("幻灯片 %d 下载失败: HTTP %d", s.SlideIndex, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
if _, err := decodeImageBytes(body); err != nil {
|
||||
return nil, fmt.Errorf("幻灯片 %d 不是有效图片: %v", s.SlideIndex, err)
|
||||
}
|
||||
out = append(out, body)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decodeImageBytes(b []byte) (image.Image, error) {
|
||||
m, _, err := image.Decode(bytes.NewReader(b))
|
||||
return m, err
|
||||
}
|
||||
|
||||
// fitSlideBoundsEMU 在幻灯片 EMU 框(与模板 p:sldSz 一致)内按原图比例 contain 居中(不裁切)
|
||||
func fitSlideBoundsEMU(imgW, imgH int) (x, y, w, h pptx.Dimension) {
|
||||
if imgW <= 0 || imgH <= 0 {
|
||||
return 0, 0, slideEmuW, slideEmuH
|
||||
}
|
||||
sw := float64(slideEmuW)
|
||||
sh := float64(slideEmuH)
|
||||
iw := float64(imgW)
|
||||
ih := float64(imgH)
|
||||
scale := sw / iw
|
||||
if ih*scale > sh {
|
||||
scale = sh / ih
|
||||
}
|
||||
wf := iw * scale
|
||||
hf := ih * scale
|
||||
w = pptx.Dimension(wf + 0.5)
|
||||
h = pptx.Dimension(hf + 0.5)
|
||||
x = pptx.Dimension((sw-wf)*0.5 + 0.5)
|
||||
y = pptx.Dimension((sh-hf)*0.5 + 0.5)
|
||||
return x, y, w, h
|
||||
}
|
||||
|
||||
func buildPDF(images [][]byte) ([]byte, error) {
|
||||
pdf := gofpdf.New("L", "mm", "A4", "")
|
||||
pdf.SetMargins(0, 0, 0)
|
||||
pdf.SetAutoPageBreak(false, 0)
|
||||
// 像素 → mm(按 96 DPI),再按页面对比缩放以 contain 放入整页
|
||||
const pxPerMM = 96.0 / 25.4
|
||||
|
||||
for i, raw := range images {
|
||||
im, err := decodeImageBytes(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("第 %d 页: %w", i+1, err)
|
||||
}
|
||||
b := im.Bounds()
|
||||
pxW := float64(b.Dx())
|
||||
pxH := float64(b.Dy())
|
||||
if pxW <= 0 || pxH <= 0 {
|
||||
return nil, fmt.Errorf("第 %d 页: 图片尺寸无效", i+1)
|
||||
}
|
||||
pdf.AddPage()
|
||||
pageW, pageH := pdf.GetPageSize()
|
||||
imgWmm := pxW / pxPerMM
|
||||
imgHmm := pxH / pxPerMM
|
||||
scale := pageW / imgWmm
|
||||
if imgHmm*scale > pageH {
|
||||
scale = pageH / imgHmm
|
||||
}
|
||||
w := imgWmm * scale
|
||||
h := imgHmm * scale
|
||||
x := (pageW - w) / 2
|
||||
y := (pageH - h) / 2
|
||||
|
||||
name := fmt.Sprintf("slide%d", i)
|
||||
opt := gofpdf.ImageOptions{ReadDpi: false}
|
||||
tp := sniffImageType(raw)
|
||||
if tp != "" {
|
||||
opt.ImageType = tp
|
||||
}
|
||||
if pdf.RegisterImageOptionsReader(name, opt, bytes.NewReader(raw)) == nil {
|
||||
return nil, fmt.Errorf("第 %d 页: 无法写入 PDF 图片", i+1)
|
||||
}
|
||||
pdf.ImageOptions(name, x, y, w, h, false, opt, 0, "")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := pdf.Output(&buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func sniffImageType(b []byte) string {
|
||||
if len(b) < 12 {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8:
|
||||
return "jpg"
|
||||
case len(b) >= 8 && string(b[0:8]) == "\x89PNG\r\n\x1a\n":
|
||||
return "png"
|
||||
case len(b) >= 6 && string(b[0:6]) == "GIF87a" || string(b[0:6]) == "GIF89a":
|
||||
return "gif"
|
||||
case len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP":
|
||||
return "webp"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildPPTX(images [][]byte) ([]byte, error) {
|
||||
if len(minimalPptxTemplate) == 0 {
|
||||
return nil, fmt.Errorf("内置 PPT 模板缺失")
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "ppt-export-*.pptx")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := tmp.Name()
|
||||
if _, err := tmp.Write(minimalPptxTemplate); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = os.Remove(path) }()
|
||||
|
||||
f, err := pptx.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, raw := range images {
|
||||
im, err := decodeImageBytes(raw)
|
||||
if err != nil {
|
||||
f.Abort()
|
||||
return nil, err
|
||||
}
|
||||
b := im.Bounds()
|
||||
ex, ey, ew, eh := fitSlideBoundsEMU(b.Dx(), b.Dy())
|
||||
slide := pptx.Slide{
|
||||
Images: []pptx.Image{
|
||||
pptx.NewImage(im, ex, ey, ew, eh),
|
||||
},
|
||||
}
|
||||
if err := f.Add(slide); err != nil {
|
||||
f.Abort()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package ppt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime"
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
"github.com/volcengine/volcengine-go-sdk/volcengine"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
var imageLogger = log.GetLogger()
|
||||
|
||||
// ImageGenerator 图片生成适配器接口
|
||||
type ImageGenerator interface {
|
||||
Provider() string
|
||||
Generate(ctx context.Context, prompt string) (string, error)
|
||||
// GenerateWithReference 图生图;referenceImages 为公网 URL 或 data:image/...;base64,...(本地文件应在调用前经 PrepareReferenceInputsForImg2Img 转换)
|
||||
GenerateWithReference(ctx context.Context, prompt string, referenceImages []string) (string, error)
|
||||
}
|
||||
|
||||
// Nano Banana 适配器(OpenAI DALL-E 风格 API)
|
||||
type nanoBananaImageGenerator struct {
|
||||
client *req.Client
|
||||
cfg types.PPTConfig
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
// Seedream 适配器(火山引擎 arkruntime SDK)
|
||||
type seedreamImageGenerator struct {
|
||||
cfg types.PPTConfig
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
// NewImageGenerator 根据配置创建对应的图片生成适配器
|
||||
func NewImageGenerator(cfg types.PPTConfig) (ImageGenerator, error) {
|
||||
qps := cfg.QPSLimit
|
||||
if qps <= 0 {
|
||||
qps = 1
|
||||
}
|
||||
limiter := rate.NewLimiter(rate.Limit(qps), 1)
|
||||
|
||||
switch cfg.ActiveImageProvider {
|
||||
case types.PPTImageProviderNanoBanana:
|
||||
if cfg.NanoBananaApiURL == "" || cfg.NanoBananaApiKey == "" {
|
||||
return nil, fmt.Errorf("nano banana api not configured")
|
||||
}
|
||||
return &nanoBananaImageGenerator{
|
||||
client: req.C().SetTimeout(3 * time.Minute),
|
||||
cfg: cfg,
|
||||
limiter: limiter,
|
||||
}, nil
|
||||
case types.PPTImageProviderSeedream:
|
||||
if cfg.SeedreamBaseURL == "" || cfg.SeedreamApiKey == "" || cfg.SeedreamModel == "" {
|
||||
return nil, fmt.Errorf("seedream api not configured")
|
||||
}
|
||||
return &seedreamImageGenerator{
|
||||
cfg: cfg,
|
||||
limiter: limiter,
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported image provider: %s", cfg.ActiveImageProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *nanoBananaImageGenerator) Provider() string {
|
||||
return string(types.PPTImageProviderNanoBanana)
|
||||
}
|
||||
|
||||
// nanoBananaReq 按 OpenAI DALL-E 风格 / Nano-banana API 文档
|
||||
type nanoBananaReq struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
ResponseFormat string `json:"response_format,omitempty"` // url 或 b64_json
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"` // 1:1, 4:3, 3:4, 16:9, 9:16, 2:3, 3:2, 4:5, 5:4, 21:9
|
||||
Image []string `json:"image,omitempty"` // 参考图 url 或 b64
|
||||
}
|
||||
|
||||
// nanoBananaRes 响应为 data[].url(DALL-E 风格)
|
||||
type nanoBananaRes struct {
|
||||
Data []struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
B64JSON string `json:"b64_json,omitempty"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type nanoBananaErr struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (g *nanoBananaImageGenerator) buildReqBody(prompt string, referenceImages []string) nanoBananaReq {
|
||||
modelName := g.cfg.NanoBananaModel
|
||||
if modelName == "" {
|
||||
modelName = "nano-banana"
|
||||
}
|
||||
reqBody := nanoBananaReq{
|
||||
Model: modelName,
|
||||
Prompt: prompt,
|
||||
}
|
||||
if len(referenceImages) > 0 {
|
||||
reqBody.Image = referenceImages
|
||||
}
|
||||
if g.cfg.NanoBananaResponseFormat != "" {
|
||||
reqBody.ResponseFormat = g.cfg.NanoBananaResponseFormat
|
||||
} else {
|
||||
reqBody.ResponseFormat = "url"
|
||||
}
|
||||
if g.cfg.NanoBananaAspectRatio != "" {
|
||||
reqBody.AspectRatio = g.cfg.NanoBananaAspectRatio
|
||||
} else {
|
||||
reqBody.AspectRatio = "16:9"
|
||||
}
|
||||
return reqBody
|
||||
}
|
||||
|
||||
func (g *nanoBananaImageGenerator) Generate(ctx context.Context, prompt string) (string, error) {
|
||||
return g.GenerateWithReference(ctx, prompt, nil)
|
||||
}
|
||||
|
||||
func (g *nanoBananaImageGenerator) GenerateWithReference(ctx context.Context, prompt string, referenceImages []string) (string, error) {
|
||||
reqBody := g.buildReqBody(prompt, referenceImages)
|
||||
|
||||
var (
|
||||
result nanoBananaRes
|
||||
errRes nanoBananaErr
|
||||
)
|
||||
|
||||
do := func() (int, error) {
|
||||
if err := g.limiter.Wait(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
imageLogger.Infof("nano banana generate image, api: %s", g.cfg.NanoBananaApiURL)
|
||||
r, err := g.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("Authorization", "Bearer "+g.cfg.NanoBananaApiKey).
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&result).
|
||||
SetErrorResult(&errRes).
|
||||
Post(g.cfg.NanoBananaApiURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if r.IsErrorState() {
|
||||
return r.StatusCode, fmt.Errorf("nano banana error: %s, %s", r.Status, errRes.Error.Message)
|
||||
}
|
||||
if len(result.Data) == 0 || result.Data[0].URL == "" {
|
||||
return r.StatusCode, fmt.Errorf("nano banana returned empty data")
|
||||
}
|
||||
return r.StatusCode, nil
|
||||
}
|
||||
|
||||
if err := callWithRetry(ctx, do); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result.Data[0].URL, nil
|
||||
}
|
||||
|
||||
func (g *seedreamImageGenerator) Provider() string {
|
||||
return string(types.PPTImageProviderSeedream)
|
||||
}
|
||||
|
||||
func (g *seedreamImageGenerator) Generate(ctx context.Context, prompt string) (string, error) {
|
||||
return g.GenerateWithReference(ctx, prompt, nil)
|
||||
}
|
||||
|
||||
func (g *seedreamImageGenerator) GenerateWithReference(ctx context.Context, prompt string, referenceImages []string) (string, error) {
|
||||
if err := g.limiter.Wait(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
client := arkruntime.NewClientWithApiKey(g.cfg.SeedreamApiKey, arkruntime.WithBaseUrl(g.cfg.SeedreamBaseURL))
|
||||
|
||||
size := g.cfg.SeedreamSize
|
||||
if size == "" {
|
||||
size = "1920x1080"
|
||||
}
|
||||
responseFormat := g.cfg.SeedreamResponseType
|
||||
if responseFormat == "" {
|
||||
responseFormat = "url"
|
||||
}
|
||||
|
||||
generateReq := model.GenerateImagesRequest{
|
||||
Model: g.cfg.SeedreamModel,
|
||||
Prompt: prompt,
|
||||
Size: volcengine.String(size),
|
||||
ResponseFormat: volcengine.String(responseFormat),
|
||||
Watermark: volcengine.Bool(g.cfg.SeedreamWatermark),
|
||||
}
|
||||
if len(referenceImages) > 0 {
|
||||
generateReq.Image = referenceImages
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * 2 * time.Second):
|
||||
}
|
||||
}
|
||||
imageLogger.Infof("seedream generate image, api: %s", g.cfg.SeedreamBaseURL)
|
||||
if err := generateReq.NormalizeImages(); err != nil {
|
||||
return "", fmt.Errorf("seedream normalize images: %w", err)
|
||||
}
|
||||
resp, err := client.GenerateImages(ctx, generateReq)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("seedream error: %w", err)
|
||||
continue
|
||||
}
|
||||
if resp.Data == nil || len(resp.Data) == 0 {
|
||||
lastErr = fmt.Errorf("seedream returned empty data")
|
||||
continue
|
||||
}
|
||||
if resp.Data[0].Url == nil || *resp.Data[0].Url == "" {
|
||||
lastErr = fmt.Errorf("seedream returned empty url")
|
||||
continue
|
||||
}
|
||||
return *resp.Data[0].Url, nil
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
// callWithRetry 对 429 错误做指数退避重试
|
||||
func callWithRetry(ctx context.Context, fn func() (int, error)) error {
|
||||
var (
|
||||
retries = 3
|
||||
backoffs = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
|
||||
lastError error
|
||||
)
|
||||
|
||||
for i := 0; i < retries; i++ {
|
||||
status, err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastError = err
|
||||
|
||||
// 仅对 429 做指数退避重试
|
||||
if status != http.StatusTooManyRequests || i == retries-1 {
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(backoffs[i]):
|
||||
}
|
||||
}
|
||||
|
||||
return lastError
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package ppt
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
var logger = log.GetLogger()
|
||||
|
||||
// slidePlan LLM 输出的分镜结构
|
||||
type slidePlan struct {
|
||||
SlideIndex int `json:"slide_index"`
|
||||
Theme string `json:"theme"`
|
||||
Title string `json:"title"`
|
||||
Points []string `json:"points"`
|
||||
ImagePrompt string `json:"image_prompt"`
|
||||
}
|
||||
|
||||
// systemPrompt 图文并茂幻灯片:每页有插图且画面上含与主题一致的文字,文字量与生成模式相关
|
||||
const systemPrompt = `
|
||||
# Role
|
||||
|
||||
你是一位顶级的专业演示文稿(PPT)策划专家和 AI 图像提示词(Prompt)工程师。任务是根据用户提供的「内容大纲」或「设计要求」,生成一套逻辑清晰、视觉风格高度统一的幻灯片分镜数据。目标是生成**图文并茂**的幻灯片:每页既有文字又有插图,图片上直接呈现与本页内容一致的文字,而不是留白让用户后加文字。
|
||||
|
||||
# Rules
|
||||
|
||||
1. 全局风格锚定:根据大纲推断或遵循用户要求的全局视觉风格。所有配图必须严格遵循此风格。
|
||||
2. 结构化拆解:合理拆分为多张幻灯片,单页最多 3-4 个简短要点。
|
||||
3. 视觉转译(图文并茂):
|
||||
- 为每页构思的 image_prompt 既要描述**插图画面**,也要明确**画面上应出现的文字**(如本页标题、要点或短句),与当页 theme、title、points 内容一致。不要描述留白或“用于排版文字的空间”。
|
||||
- 图片中出现的所有文字必须使用与 theme、title、points **相同的语言**(即本次请求指定的输出语言)。
|
||||
- 图片上文字的量由「生成模式」决定(见用户输入中的模式说明):
|
||||
- **详细演示文稿**:图片上的说明文字可适当多一些,如本页要点、一两句说明。
|
||||
- **演示用幻灯片**:图片上的文字尽量精简,如仅主标题或少量关键词,便于演讲时配合口述。
|
||||
- image_prompt 须包含前缀「[全局风格描述]」,并清晰描述画面中的插图与文字内容(含具体要出现的文字及其语言),不要包含“不要在图片中生成任何文字”的约束。
|
||||
4. 严格输出合法的纯 JSON 数组:
|
||||
[
|
||||
{"slide_index": 1, "theme": "...", "title": "...", "points": ["..."], "image_prompt": "..."}
|
||||
]
|
||||
禁止输出任何 Markdown 标记或多余文本。`
|
||||
|
||||
// notebookSystemPrompt 文档提炼:NotebookLM 风格输出 PPT 可用大纲文本(纯文本/Markdown)。
|
||||
const notebookSystemPrompt = `
|
||||
# Role
|
||||
|
||||
你是一位“NotebookLM 风格”的专业文档理解与提炼助手。任务是基于用户提供的「原始文档文本」和「设计要求」,提炼出可用于制作 PPT 的结构化大纲内容。
|
||||
|
||||
# Output Requirements
|
||||
|
||||
1. 输出必须是纯文本/Markdown(允许使用标题与列表),禁止输出任何 JSON。
|
||||
2. 禁止输出代码块(不要出现代码块语法)。
|
||||
3. 不要输出解释过程、不要复述提示词。
|
||||
4. 大纲必须是“内容大纲/要点”,用于后续继续拆分成幻灯片,而不是直接输出最终幻灯片分镜。
|
||||
|
||||
# Rules
|
||||
|
||||
1. 文档优先:尽可能从原始文档中提取信息与措辞;若文档缺失关键点,则给出合理补全的“建议方向”,并明确标注为“(建议)”。
|
||||
2. 贴合设计要求:根据设计要求调整大纲的语气、侧重点、术语风格,使内容更符合目标受众与整体风格。
|
||||
3. 结构清晰:使用分层标题(例如:# 总主题、## 模块/章节、### 要点),并为每个模块给出 2-4 个要点句(可直接用于 PPT 每页标题/要点)。
|
||||
4. 语言一致:所有输出语言必须与本次请求指定的语言一致。
|
||||
`
|
||||
|
||||
// LLMClient 分镜 LLM 客户端
|
||||
type LLMClient struct {
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
func NewLLMClient() *LLMClient {
|
||||
return &LLMClient{
|
||||
httpClient: req.C().SetTimeout(2 * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSlides 调用大模型生成分镜列表。language 约束输出语言,mode 约束图中文字量,maxPages 约束恰好生成 N 页。
|
||||
func (c *LLMClient) GenerateSlides(ctx context.Context, cfg types.PPTConfig, content, prompt, language, mode string, maxPages int) ([]slidePlan, error) {
|
||||
if cfg.OutlineLLMApiURL == "" {
|
||||
return nil, fmt.Errorf("outline LLM api url is empty")
|
||||
}
|
||||
if cfg.OutlineLLMApiKey == "" {
|
||||
return nil, fmt.Errorf("outline LLM api key is empty")
|
||||
}
|
||||
if maxPages <= 0 {
|
||||
maxPages = 10
|
||||
}
|
||||
if mode != "detailed" && mode != "slides" {
|
||||
mode = "slides"
|
||||
}
|
||||
|
||||
type message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// 动态 system:加入页数约束
|
||||
systemContent := systemPrompt + fmt.Sprintf("\n\n# 页数约束\n请将内容拆分为恰好 %d 页的幻灯片分镜,保证逻辑完整、故事线连贯,不要多也不要少。输出 JSON 数组长度必须为 %d。", maxPages, maxPages)
|
||||
|
||||
// 组装用户输入
|
||||
userContent := fmt.Sprintf("下面是用户提供的演示文稿大纲内容:\n\n%s", content)
|
||||
if prompt != "" {
|
||||
userContent = fmt.Sprintf("%s\n\n额外的设计要求:%s", userContent, prompt)
|
||||
}
|
||||
if language != "" {
|
||||
langHint := "中文"
|
||||
if language == "en" || language == "en-US" {
|
||||
langHint = "英文"
|
||||
} else if language == "zh-CN" || language == "zh" {
|
||||
langHint = "中文"
|
||||
} else {
|
||||
langHint = "语言代码 " + language + " 对应的语言"
|
||||
}
|
||||
userContent = fmt.Sprintf("%s\n\n请用%s输出所有分镜内容(theme、title、points、image_prompt 等均使用该语言;图片中出现的文字也必须是%s)。", userContent, langHint, langHint)
|
||||
}
|
||||
modeHint := "演示用幻灯片"
|
||||
if mode == "detailed" {
|
||||
modeHint = "详细演示文稿"
|
||||
}
|
||||
userContent = fmt.Sprintf("%s\n\n本次生成模式为:%s。请按上述规则控制每页插图中文字的量。", userContent, modeHint)
|
||||
|
||||
modelName := cfg.OutlineLLMModel
|
||||
if modelName == "" {
|
||||
modelName = "gpt-5.2"
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": modelName,
|
||||
"messages": []message{
|
||||
{Role: "user", Content: systemContent + "\n\n" + userContent},
|
||||
},
|
||||
"temperature": 0.8,
|
||||
}
|
||||
|
||||
var respBody struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
logger.Infof("generate PPT slides with outline LLM, api: %s", cfg.OutlineLLMApiURL)
|
||||
r, err := c.httpClient.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("Authorization", "Bearer "+cfg.OutlineLLMApiKey).
|
||||
SetBody(body).
|
||||
SetSuccessResult(&respBody).
|
||||
Post(cfg.OutlineLLMApiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request outline LLM failed: %v", err)
|
||||
}
|
||||
if r.IsErrorState() {
|
||||
return nil, fmt.Errorf("outline LLM returned error status: %s", r.Status)
|
||||
}
|
||||
|
||||
if len(respBody.Choices) == 0 {
|
||||
return nil, fmt.Errorf("outline LLM returned empty choices")
|
||||
}
|
||||
|
||||
contentStr := respBody.Choices[0].Message.Content
|
||||
var plans []slidePlan
|
||||
if err := json.Unmarshal([]byte(contentStr), &plans); err != nil {
|
||||
return nil, fmt.Errorf("parse outline LLM json failed: %v, raw: %s", err, contentStr)
|
||||
}
|
||||
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
// GenerateNotebookContent 调用文档提炼 LLM,把 rawDocText -> PPT 可用的 content(大纲/结构化要点)。
|
||||
func (c *LLMClient) GenerateNotebookContent(ctx context.Context, cfg types.PPTConfig, rawDocText, designPrompt, language string) (string, error) {
|
||||
if cfg.OutlineLLMApiURL == "" {
|
||||
return "", fmt.Errorf("outline LLM api url is empty")
|
||||
}
|
||||
if cfg.OutlineLLMApiKey == "" {
|
||||
return "", fmt.Errorf("outline LLM api key is empty")
|
||||
}
|
||||
|
||||
rawDocText = strings.TrimSpace(rawDocText)
|
||||
if rawDocText == "" {
|
||||
return "", fmt.Errorf("rawDocText is empty")
|
||||
}
|
||||
|
||||
// 对超长输入做保守截断,避免请求体过大或上下文溢出。
|
||||
// 这里按“字符数”截断,真实 token 仍可能超出,但作为兜底足够。
|
||||
const maxChars = 25000
|
||||
runes := []rune(rawDocText)
|
||||
if len(runes) > maxChars {
|
||||
rawDocText = string(runes[:maxChars])
|
||||
}
|
||||
|
||||
langHint := "中文"
|
||||
if language == "en" || language == "en-US" {
|
||||
langHint = "英文"
|
||||
} else if language == "zh-CN" || language == "zh" {
|
||||
langHint = "中文"
|
||||
} else if language != "" {
|
||||
langHint = "语言代码 " + language + " 对应的语言"
|
||||
}
|
||||
|
||||
maxSlides := cfg.MaxSlidesPerTask
|
||||
if maxSlides <= 0 {
|
||||
maxSlides = 10
|
||||
}
|
||||
|
||||
userContent := fmt.Sprintf("原始文档文本如下(可能很长):\n\n%s", rawDocText)
|
||||
if strings.TrimSpace(designPrompt) != "" {
|
||||
userContent = fmt.Sprintf("%s\n\n设计要求(风格/受众/侧重点等):\n%s", userContent, designPrompt)
|
||||
}
|
||||
userContent = fmt.Sprintf(
|
||||
"%s\n\n请用%s输出 PPT 大纲内容。该大纲应便于拆分为不超过 %d 页的 PPT。",
|
||||
userContent,
|
||||
langHint,
|
||||
maxSlides,
|
||||
)
|
||||
|
||||
type message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
systemContent := notebookSystemPrompt + fmt.Sprintf("\n\n# 语言约束\n输出语言:%s。", langHint)
|
||||
|
||||
modelName := cfg.OutlineLLMModel
|
||||
if modelName == "" {
|
||||
modelName = "gpt-4o-mini"
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"model": modelName,
|
||||
"messages": []message{
|
||||
{Role: "user", Content: systemContent + "\n\n" + userContent},
|
||||
},
|
||||
"temperature": 0.4,
|
||||
}
|
||||
|
||||
var respBody struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
logger.Infof("generate PPT content with outline LLM, api: %s", cfg.OutlineLLMApiURL)
|
||||
r, err := c.httpClient.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("Authorization", "Bearer "+cfg.OutlineLLMApiKey).
|
||||
SetBody(body).
|
||||
SetSuccessResult(&respBody).
|
||||
Post(cfg.OutlineLLMApiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request outline LLM failed: %v", err)
|
||||
}
|
||||
if r.IsErrorState() {
|
||||
return "", fmt.Errorf("outline LLM returned error status: %s", r.Status)
|
||||
}
|
||||
if len(respBody.Choices) == 0 {
|
||||
return "", fmt.Errorf("outline LLM returned empty choices")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(respBody.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
// titleSystemPrompt 根据大纲生成用于任务列表的短标题(单行纯文本)
|
||||
const titleSystemPrompt = `
|
||||
# Role
|
||||
|
||||
你是「演示文稿命名助手」。用户会提供一份 PPT 内容大纲(可能含 Markdown)。请根据大纲主题与受众,生成一个**适合出现在任务列表中的短标题**。
|
||||
|
||||
# Output Rules
|
||||
|
||||
1. 只输出**一行**纯文本,不要换行、不要编号、不要引号包裹。
|
||||
2. 长度建议 **20 个字以内**(中文)或 **8 个英文单词以内**;若大纲极长,仍只给概括性标题。
|
||||
3. 输出语言必须与本次指定的「输出语言」一致。
|
||||
4. 不要输出“标题:”“Title:”等前缀,不要复述本说明。
|
||||
`
|
||||
|
||||
// GeneratePPTTitle 调用大模型根据 content 生成列表用短标题;失败时由调用方降级。
|
||||
func (c *LLMClient) GeneratePPTTitle(ctx context.Context, cfg types.PPTConfig, content, language string) (string, error) {
|
||||
if cfg.OutlineLLMApiURL == "" {
|
||||
return "", fmt.Errorf("outline LLM api url is empty")
|
||||
}
|
||||
if cfg.OutlineLLMApiKey == "" {
|
||||
return "", fmt.Errorf("outline LLM api key is empty")
|
||||
}
|
||||
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("content is empty")
|
||||
}
|
||||
|
||||
const maxChars = 10000
|
||||
runes := []rune(content)
|
||||
if len(runes) > maxChars {
|
||||
content = string(runes[:maxChars])
|
||||
}
|
||||
|
||||
langHint := "中文"
|
||||
if language == "en" || language == "en-US" {
|
||||
langHint = "英文"
|
||||
} else if language == "zh-CN" || language == "zh" {
|
||||
langHint = "中文"
|
||||
} else if language != "" {
|
||||
langHint = "语言代码 " + language + " 对应的语言"
|
||||
}
|
||||
|
||||
type message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
userContent := fmt.Sprintf("输出语言:%s。\n\n下面是用户提供的 PPT 大纲内容,请只返回列表标题:\n\n%s", langHint, content)
|
||||
systemContent := titleSystemPrompt + fmt.Sprintf("\n\n# 语言约束\n请用%s撰写标题。", langHint)
|
||||
|
||||
modelName := cfg.OutlineLLMModel
|
||||
if modelName == "" {
|
||||
modelName = "gpt-4o-mini"
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"model": modelName,
|
||||
"messages": []message{
|
||||
{Role: "user", Content: systemContent + "\n\n" + userContent},
|
||||
},
|
||||
"temperature": 0.5,
|
||||
}
|
||||
|
||||
var respBody struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
logger.Infof("generate PPT list title with outline LLM, api: %s", cfg.OutlineLLMApiURL)
|
||||
r, err := c.httpClient.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("Authorization", "Bearer "+cfg.OutlineLLMApiKey).
|
||||
SetBody(body).
|
||||
SetSuccessResult(&respBody).
|
||||
Post(cfg.OutlineLLMApiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request outline LLM failed: %v", err)
|
||||
}
|
||||
if r.IsErrorState() {
|
||||
return "", fmt.Errorf("outline LLM returned error status: %s", r.Status)
|
||||
}
|
||||
if len(respBody.Choices) == 0 {
|
||||
return "", fmt.Errorf("outline LLM returned empty choices")
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(respBody.Choices[0].Message.Content)
|
||||
if idx := strings.IndexAny(raw, "\r\n"); idx >= 0 {
|
||||
raw = strings.TrimSpace(raw[:idx])
|
||||
}
|
||||
raw = strings.Trim(raw, `"'「」`)
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
package ppt
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrInsufficientPower 用户算力不足以完成本次 PPT 任务(由 handler 映射文案)
|
||||
var ErrInsufficientPower = errors.New("insufficient power for ppt task")
|
||||
|
||||
var (
|
||||
// ErrPptTaskNotFound 表示任务不存在
|
||||
ErrPptTaskNotFound = errors.New("ppt task not found")
|
||||
// ErrPptTaskNotDeletable 表示任务状态不允许删除
|
||||
ErrPptTaskNotDeletable = errors.New("ppt task not deletable")
|
||||
// ErrPptTaskBusy 任务正在处理中
|
||||
ErrPptTaskBusy = errors.New("ppt task is processing")
|
||||
// ErrPptTaskNotResumable 无法继续生成(无分镜占位或已完成)
|
||||
ErrPptTaskNotResumable = errors.New("ppt task cannot be resumed")
|
||||
// ErrPptSlideNotFound 指定 slide_index 不存在
|
||||
ErrPptSlideNotFound = errors.New("ppt slide not found")
|
||||
// ErrPptSlideNoImage 该页尚无配图
|
||||
ErrPptSlideNoImage = errors.New("ppt slide has no image")
|
||||
// ErrPptInvalidVersionIndex 历史版本下标无效
|
||||
ErrPptInvalidVersionIndex = errors.New("invalid slide version index")
|
||||
)
|
||||
|
||||
// TaskStatus 任务状态
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusPending TaskStatus = "pending"
|
||||
TaskStatusProcessing TaskStatus = "processing"
|
||||
TaskStatusCompleted TaskStatus = "completed"
|
||||
TaskStatusFailed TaskStatus = "failed"
|
||||
)
|
||||
|
||||
// SlideData 单页 PPT 数据
|
||||
type SlideData struct {
|
||||
SlideIndex int `json:"slide_index"`
|
||||
Theme string `json:"theme"`
|
||||
Title string `json:"title"`
|
||||
Points []string `json:"points"`
|
||||
ImagePrompt string `json:"image_prompt"`
|
||||
ImageURL string `json:"image_url"`
|
||||
ImageHistory []vo.PPTSlideImageVersion `json:"image_history,omitempty"`
|
||||
}
|
||||
|
||||
// Task PPT 生成任务(用于业务层与 API 返回,持久化在 DB)
|
||||
type Task struct {
|
||||
TaskID string `json:"task_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Content string `json:"content"`
|
||||
Prompt string `json:"prompt"`
|
||||
Language string `json:"language"`
|
||||
Mode string `json:"mode"`
|
||||
Pages int `json:"pages"`
|
||||
Total int `json:"total_slides"`
|
||||
Completed int `json:"completed_slides"`
|
||||
Slides []SlideData `json:"slides"`
|
||||
Title string `json:"title"`
|
||||
Thumb string `json:"thumb"`
|
||||
|
||||
ErrorMessage string `json:"error_message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskSummaryMap 返回任务摘要 map(公共字段),供 handler 补充独有字段后返回。
|
||||
func (t *Task) TaskSummaryMap() map[string]any {
|
||||
return map[string]any{
|
||||
"task_id": t.TaskID,
|
||||
"status": t.Status,
|
||||
"total_slides": t.Total,
|
||||
"completed_slides": t.Completed,
|
||||
"created_at": t.CreatedAt.Unix(),
|
||||
"updated_at": t.UpdatedAt.Unix(),
|
||||
"title": t.Title,
|
||||
"thumb": t.Thumb,
|
||||
}
|
||||
}
|
||||
|
||||
// Progress 任务进度信息
|
||||
type Progress struct {
|
||||
Total int `json:"total_slides"`
|
||||
Completed int `json:"completed_slides"`
|
||||
}
|
||||
|
||||
// PptService PPT 任务与生成流程(持久化、LLM 分镜、生图、转存、算力)
|
||||
type PptService struct {
|
||||
db *gorm.DB
|
||||
userService *service.UserService
|
||||
uploadManager *oss.UploaderManager
|
||||
llm *LLMClient
|
||||
// slidesLock 全局互斥:串行化 slides JSON 的写库,避免并发覆盖(写库极短,可接受排队)
|
||||
slidesLock sync.Mutex
|
||||
}
|
||||
|
||||
// NewPptService 创建 PptService
|
||||
func NewPptService(db *gorm.DB, userService *service.UserService, uploadManager *oss.UploaderManager) *PptService {
|
||||
return &PptService{
|
||||
db: db,
|
||||
userService: userService,
|
||||
uploadManager: uploadManager,
|
||||
llm: NewLLMClient(),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateNotebookContent 将原始文档文本提炼成 PPT 可用的大纲 content。
|
||||
func (s *PptService) GenerateNotebookContent(ctx context.Context, cfg types.PPTConfig, rawDocText, designPrompt, language string) (string, error) {
|
||||
if s.llm == nil {
|
||||
s.llm = NewLLMClient()
|
||||
}
|
||||
return s.llm.GenerateNotebookContent(ctx, cfg, rawDocText, designPrompt, language)
|
||||
}
|
||||
|
||||
func slideToVO(s SlideData) vo.PPTSlideData {
|
||||
return vo.PPTSlideData{
|
||||
SlideIndex: s.SlideIndex,
|
||||
Theme: s.Theme,
|
||||
Title: s.Title,
|
||||
Points: s.Points,
|
||||
ImagePrompt: s.ImagePrompt,
|
||||
ImageURL: s.ImageURL,
|
||||
ImageHistory: vo.PPTSlideImageVersions(s.ImageHistory),
|
||||
}
|
||||
}
|
||||
|
||||
func voToSlide(s vo.PPTSlideData) SlideData {
|
||||
return SlideData{
|
||||
SlideIndex: s.SlideIndex,
|
||||
Theme: s.Theme,
|
||||
Title: s.Title,
|
||||
Points: s.Points,
|
||||
ImagePrompt: s.ImagePrompt,
|
||||
ImageURL: s.ImageURL,
|
||||
ImageHistory: []vo.PPTSlideImageVersion(s.ImageHistory),
|
||||
}
|
||||
}
|
||||
|
||||
func voSlidesToBiz(slides vo.PPTSlides) []SlideData {
|
||||
out := make([]SlideData, len(slides))
|
||||
for i, sv := range slides {
|
||||
out[i] = voToSlide(sv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func voSlidesToBizNormalized(slides vo.PPTSlides) []SlideData {
|
||||
out := make([]SlideData, len(slides))
|
||||
for i, sv := range slides {
|
||||
sd := voToSlide(sv)
|
||||
normalizeSlideImageHistory(&sd)
|
||||
out[i] = sd
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeSlideImageHistory 旧数据仅有 image_url 时补全 image_history,便于前端展示历史
|
||||
func normalizeSlideImageHistory(s *SlideData) {
|
||||
if strings.TrimSpace(s.ImageURL) != "" && len(s.ImageHistory) == 0 {
|
||||
s.ImageHistory = []vo.PPTSlideImageVersion{
|
||||
{ImageURL: s.ImageURL, Prompt: strings.TrimSpace(s.ImagePrompt)},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DerivePPTThumbFromSlides 按 slide_index 升序取第一张有图 URL(与 vo.PPTSlides 规则一致)
|
||||
func DerivePPTThumbFromSlides(slides []SlideData) string {
|
||||
if len(slides) == 0 {
|
||||
return ""
|
||||
}
|
||||
cp := make([]SlideData, len(slides))
|
||||
copy(cp, slides)
|
||||
sort.Slice(cp, func(i, j int) bool {
|
||||
return cp[i].SlideIndex < cp[j].SlideIndex
|
||||
})
|
||||
for _, s := range cp {
|
||||
if strings.TrimSpace(s.ImageURL) != "" {
|
||||
return s.ImageURL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateTitleRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
func taskToModel(t *Task) *model.PPTJob {
|
||||
now := time.Now()
|
||||
job := &model.PPTJob{
|
||||
TaskId: t.TaskID,
|
||||
UserId: t.UserID,
|
||||
Status: string(t.Status),
|
||||
ErrMsg: t.ErrorMessage,
|
||||
Prompt: t.Prompt,
|
||||
Title: t.Title,
|
||||
Thumb: t.Thumb,
|
||||
Content: t.Content,
|
||||
Params: vo.PPTParams{Language: t.Language, Mode: t.Mode, Pages: t.Pages},
|
||||
Slides: nil,
|
||||
TotalSlides: t.Total,
|
||||
CompletedSlides: t.Completed,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if t.CreatedAt.IsZero() {
|
||||
job.CreatedAt = now
|
||||
job.UpdatedAt = now
|
||||
} else {
|
||||
job.CreatedAt = t.CreatedAt
|
||||
job.UpdatedAt = t.UpdatedAt
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
func modelToTask(j *model.PPTJob) *Task {
|
||||
slides := voSlidesToBizNormalized(j.Slides)
|
||||
return &Task{
|
||||
TaskID: j.TaskId,
|
||||
UserID: j.UserId,
|
||||
Status: TaskStatus(j.Status),
|
||||
Content: j.Content,
|
||||
Prompt: j.Prompt,
|
||||
Title: j.Title,
|
||||
Thumb: j.Thumb,
|
||||
Language: j.Params.Language,
|
||||
Mode: j.Params.Mode,
|
||||
Pages: j.Params.Pages,
|
||||
Total: j.TotalSlides,
|
||||
Completed: j.CompletedSlides,
|
||||
Slides: slides,
|
||||
ErrorMessage: j.ErrMsg,
|
||||
CreatedAt: j.CreatedAt,
|
||||
UpdatedAt: j.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildPendingTask 校验算力与页数,组装待写入的 Task(未落库)
|
||||
func (s *PptService) BuildPendingTask(taskID string, userID uint, userPower int, content, prompt, language, mode string, reqPages int) (*Task, types.PPTConfig, error) {
|
||||
cfg, err := s.loadPPTConfig()
|
||||
if err != nil {
|
||||
return nil, cfg, err
|
||||
}
|
||||
|
||||
// 目标页数:用户指定时取 min(请求页数, 服务端上限);未指定(0)时按服务端上限作为默认生成规模
|
||||
effectivePages := cfg.MaxSlidesPerTask
|
||||
if reqPages > 0 {
|
||||
effectivePages = reqPages
|
||||
if effectivePages > cfg.MaxSlidesPerTask {
|
||||
effectivePages = cfg.MaxSlidesPerTask
|
||||
}
|
||||
}
|
||||
|
||||
estimatePower := effectivePages * cfg.PowerCostPerSlide
|
||||
if estimatePower > 0 && userPower < estimatePower {
|
||||
return nil, cfg, ErrInsufficientPower
|
||||
}
|
||||
|
||||
effectiveMode := mode
|
||||
if effectiveMode != "detailed" && effectiveMode != "slides" {
|
||||
effectiveMode = "slides"
|
||||
}
|
||||
|
||||
task := &Task{
|
||||
TaskID: taskID,
|
||||
UserID: userID,
|
||||
Status: TaskStatusPending,
|
||||
Content: content,
|
||||
Prompt: prompt,
|
||||
Language: language,
|
||||
Pages: effectivePages,
|
||||
Mode: effectiveMode,
|
||||
}
|
||||
return task, cfg, nil
|
||||
}
|
||||
|
||||
// CreateTask 创建新任务并写入数据库(调用大模型生成列表标题后落库)
|
||||
func (s *PptService) CreateTask(ctx context.Context, task *Task, cfg types.PPTConfig) error {
|
||||
if s.llm == nil {
|
||||
s.llm = NewLLMClient()
|
||||
}
|
||||
title, err := s.llm.GeneratePPTTitle(ctx, cfg, task.Content, task.Language)
|
||||
if err != nil {
|
||||
logger.Warnf("GeneratePPTTitle failed task_id=%s: %v", task.TaskID, err)
|
||||
title = "未命名演示文稿"
|
||||
} else {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = "未命名演示文稿"
|
||||
}
|
||||
}
|
||||
task.Title = truncateTitleRunes(title, 255)
|
||||
|
||||
task.CreatedAt = time.Now()
|
||||
task.UpdatedAt = task.CreatedAt
|
||||
task.Status = TaskStatusPending
|
||||
job := taskToModel(task)
|
||||
return s.db.Create(job).Error
|
||||
}
|
||||
|
||||
// GetTask 从数据库获取任务
|
||||
func (s *PptService) GetTask(taskID string) (*Task, bool) {
|
||||
var job model.PPTJob
|
||||
err := s.db.Where("task_id = ?", taskID).First(&job).Error
|
||||
if err != nil || job.TaskId == "" {
|
||||
return nil, false
|
||||
}
|
||||
return modelToTask(&job), true
|
||||
}
|
||||
|
||||
// UpdateStatus 更新任务状态
|
||||
func (s *PptService) UpdateStatus(taskID string, status TaskStatus) {
|
||||
s.db.Model(&model.PPTJob{}).Where("task_id = ?", taskID).
|
||||
Updates(map[string]interface{}{"status": string(status), "updated_at": time.Now()})
|
||||
}
|
||||
|
||||
func countSlidesWithImage(slides []SlideData) int {
|
||||
n := 0
|
||||
for _, sl := range slides {
|
||||
if strings.TrimSpace(sl.ImageURL) != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func slidePlansToOutlines(plans []slidePlan) []SlideData {
|
||||
out := make([]SlideData, len(plans))
|
||||
for i, p := range plans {
|
||||
out[i] = SlideData{
|
||||
SlideIndex: p.SlideIndex,
|
||||
Theme: p.Theme,
|
||||
Title: p.Title,
|
||||
Points: p.Points,
|
||||
ImagePrompt: p.ImagePrompt,
|
||||
ImageURL: "",
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].SlideIndex < out[j].SlideIndex
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// saveSlidesOutline 分镜一出即落库:每页含 theme/title/points/image_prompt,image_url 为空
|
||||
func (s *PptService) saveSlidesOutline(taskID string, total int, slides []SlideData) error {
|
||||
s.slidesLock.Lock()
|
||||
defer s.slidesLock.Unlock()
|
||||
|
||||
voSlides := make(vo.PPTSlides, len(slides))
|
||||
for i := range slides {
|
||||
voSlides[i] = slideToVO(slides[i])
|
||||
}
|
||||
completed := countSlidesWithImage(slides)
|
||||
return s.db.Model(&model.PPTJob{}).Where("task_id = ?", taskID).Updates(map[string]interface{}{
|
||||
"slides": voSlides,
|
||||
"total_slides": total,
|
||||
"completed_slides": completed,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ApplySlideImage 按 slide_index 原地写入 image_url,并刷新 completed_slides、thumb
|
||||
func (s *PptService) ApplySlideImage(taskID string, slide SlideData) error {
|
||||
s.slidesLock.Lock()
|
||||
defer s.slidesLock.Unlock()
|
||||
|
||||
var job model.PPTJob
|
||||
if err := s.db.Where("task_id = ?", taskID).First(&job).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slides := job.Slides
|
||||
found := false
|
||||
for i := range slides {
|
||||
if slides[i].SlideIndex == slide.SlideIndex {
|
||||
slides[i].ImageURL = slide.ImageURL
|
||||
if strings.TrimSpace(slide.ImageURL) != "" && len(slides[i].ImageHistory) == 0 {
|
||||
slides[i].ImageHistory = vo.PPTSlideImageVersions{
|
||||
{ImageURL: slide.ImageURL, Prompt: strings.TrimSpace(slide.ImagePrompt)},
|
||||
}
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("slide index %d not found", slide.SlideIndex)
|
||||
}
|
||||
job.Slides = slides
|
||||
biz := voSlidesToBiz(slides)
|
||||
return s.refreshJobMeta(&job, biz)
|
||||
}
|
||||
|
||||
// refreshJobMeta 刷新 job 的 CompletedSlides/Thumb/UpdatedAt 并 Save。
|
||||
// 调用前必须已持有 slidesLock。
|
||||
func (s *PptService) refreshJobMeta(job *model.PPTJob, biz []SlideData) error {
|
||||
job.CompletedSlides = countSlidesWithImage(biz)
|
||||
job.Thumb = DerivePPTThumbFromSlides(biz)
|
||||
job.UpdatedAt = time.Now()
|
||||
return s.db.Save(job).Error
|
||||
}
|
||||
|
||||
func (s *PptService) validateSlideOutline(task *Task) error {
|
||||
if task.Total <= 0 {
|
||||
return ErrPptTaskNotResumable
|
||||
}
|
||||
if len(task.Slides) < task.Total {
|
||||
return ErrPptTaskNotResumable
|
||||
}
|
||||
seen := make(map[int]bool, len(task.Slides))
|
||||
for _, sl := range task.Slides {
|
||||
seen[sl.SlideIndex] = true
|
||||
}
|
||||
for i := 1; i <= task.Total; i++ {
|
||||
if !seen[i] {
|
||||
return ErrPptTaskNotResumable
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func slidesNeedingImages(task *Task) []SlideData {
|
||||
var need []SlideData
|
||||
for _, sl := range task.Slides {
|
||||
if strings.TrimSpace(sl.ImageURL) == "" {
|
||||
need = append(need, sl)
|
||||
}
|
||||
}
|
||||
sort.Slice(need, func(i, j int) bool {
|
||||
return need[i].SlideIndex < need[j].SlideIndex
|
||||
})
|
||||
return need
|
||||
}
|
||||
|
||||
func (s *PptService) userPower(userID uint) (int, error) {
|
||||
var u model.User
|
||||
if err := s.db.Where("id = ?", userID).First(&u).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return u.Power, nil
|
||||
}
|
||||
|
||||
// runSlideImageJobs 为给定幻灯片列表并发生图(每张成功后 ApplySlideImage)
|
||||
func (s *PptService) runSlideImageJobs(ctx context.Context, task *Task, cfg types.PPTConfig, generator ImageGenerator, slides []SlideData) error {
|
||||
if len(slides) == 0 {
|
||||
return nil
|
||||
}
|
||||
if cfg.MaxConcurrentRequests <= 0 {
|
||||
cfg.MaxConcurrentRequests = 3
|
||||
}
|
||||
group, ctx := errgroup.WithContext(ctx)
|
||||
group.SetLimit(cfg.MaxConcurrentRequests)
|
||||
for _, item := range slides {
|
||||
slide := item
|
||||
group.Go(func() error {
|
||||
imgURL, err := generator.Generate(ctx, slide.ImagePrompt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storedURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(imgURL, ".png", false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("转存图片失败:%w", err)
|
||||
}
|
||||
full := slide
|
||||
full.ImageURL = storedURL
|
||||
if err := s.ApplySlideImage(task.TaskID, full); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.PowerCostPerSlide > 0 {
|
||||
err = s.userService.DecreasePower(task.UserID, cfg.PowerCostPerSlide, model.PowerLog{
|
||||
Type: types.PowerConsume,
|
||||
Model: generator.Provider(),
|
||||
Remark: fmt.Sprintf("PPT 任务 %s 第 %d 页图片生成", task.TaskID, slide.SlideIndex),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("扣减算力失败:%v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// startSlideImageGenerationAsync 在后台为 missing 页并发生图;若 setProcessingBeforeRun 为 true 则先置为 processing(用户主动 resume)。
|
||||
func (s *PptService) startSlideImageGenerationAsync(task *Task, missing []SlideData, setProcessingBeforeRun bool) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg, err := s.loadPPTConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cost := len(missing) * cfg.PowerCostPerSlide
|
||||
if cost > 0 {
|
||||
power, err := s.userPower(task.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if power < cost {
|
||||
return ErrInsufficientPower
|
||||
}
|
||||
}
|
||||
|
||||
generator, err := NewImageGenerator(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化图片生成器失败:%w", err)
|
||||
}
|
||||
|
||||
if setProcessingBeforeRun {
|
||||
s.UpdateStatus(task.TaskID, TaskStatusProcessing)
|
||||
}
|
||||
t := task
|
||||
go func() {
|
||||
bg := context.Background()
|
||||
if err := s.runSlideImageJobs(bg, t, cfg, generator, missing); err != nil {
|
||||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("图片生成失败:%v", err))
|
||||
return
|
||||
}
|
||||
s.UpdateStatus(task.TaskID, TaskStatusCompleted)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoverStaleProcessingTasks 进程启动时扫描 DB 中仍为 processing 且存在缺图页的任务,重新拉起生图协程(用于服务中断后的恢复)。
|
||||
func (s *PptService) RecoverStaleProcessingTasks() {
|
||||
var jobs []model.PPTJob
|
||||
if err := s.db.Where("status = ?", string(TaskStatusProcessing)).Find(&jobs).Error; err != nil {
|
||||
logger.Warnf("PPT recover: list processing jobs failed: %v", err)
|
||||
return
|
||||
}
|
||||
for i := range jobs {
|
||||
task := modelToTask(&jobs[i])
|
||||
missing := slidesNeedingImages(task)
|
||||
if len(missing) == 0 {
|
||||
s.UpdateStatus(task.TaskID, TaskStatusCompleted)
|
||||
logger.Infof("PPT recover: task %s was processing but all slides had images, marked completed", task.TaskID)
|
||||
continue
|
||||
}
|
||||
if err := s.validateSlideOutline(task); err != nil {
|
||||
logger.Warnf("PPT recover: task %s skip (invalid outline): %v", task.TaskID, err)
|
||||
continue
|
||||
}
|
||||
if err := s.startSlideImageGenerationAsync(task, missing, false); err != nil {
|
||||
if errors.Is(err, ErrInsufficientPower) {
|
||||
logger.Warnf("PPT recover: task %s skip (insufficient power for %d slides)", task.TaskID, len(missing))
|
||||
continue
|
||||
}
|
||||
logger.Warnf("PPT recover: task %s failed to restart: %v", task.TaskID, err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("PPT recover: restarted image generation for task %s (%d slides)", task.TaskID, len(missing))
|
||||
}
|
||||
}
|
||||
|
||||
// ResumeTask 继续为缺图页生图(需完整分镜占位;processing 时返回 ErrPptTaskBusy)
|
||||
func (s *PptService) ResumeTask(ctx context.Context, taskID string, userID uint) error {
|
||||
task, ok := s.GetTask(taskID)
|
||||
if !ok {
|
||||
return ErrPptTaskNotFound
|
||||
}
|
||||
if task.UserID != userID {
|
||||
return ErrPptTaskNotFound
|
||||
}
|
||||
if task.Status == TaskStatusProcessing {
|
||||
return ErrPptTaskBusy
|
||||
}
|
||||
if task.Status == TaskStatusCompleted {
|
||||
return ErrPptTaskNotResumable
|
||||
}
|
||||
if err := s.validateSlideOutline(task); err != nil {
|
||||
return err
|
||||
}
|
||||
missing := slidesNeedingImages(task)
|
||||
if len(missing) == 0 {
|
||||
s.UpdateStatus(taskID, TaskStatusCompleted)
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.startSlideImageGenerationAsync(task, missing, true)
|
||||
}
|
||||
|
||||
// MarkAsFailed 标记任务失败
|
||||
func (s *PptService) MarkAsFailed(taskID string, msg string) {
|
||||
s.db.Model(&model.PPTJob{}).Where("task_id = ?", taskID).
|
||||
Updates(map[string]interface{}{"status": string(TaskStatusFailed), "err_msg": msg, "updated_at": time.Now()})
|
||||
}
|
||||
|
||||
// DeleteTask 删除任务并删除关联生成图片
|
||||
// 仅允许删除 completed / failed 状态的任务,避免并发任务生成过程被打断。
|
||||
func (s *PptService) DeleteTask(taskID string, userID uint) error {
|
||||
var job model.PPTJob
|
||||
if err := s.db.Where("task_id = ? AND user_id = ?", taskID, userID).First(&job).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || job.TaskId == "" {
|
||||
return ErrPptTaskNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if job.Status != string(TaskStatusCompleted) && job.Status != string(TaskStatusFailed) {
|
||||
return ErrPptTaskNotDeletable
|
||||
}
|
||||
|
||||
// 删除所有幻灯片对应的图片对象。
|
||||
uploader := s.uploadManager.GetUploadHandler()
|
||||
for _, slide := range job.Slides {
|
||||
if slide.ImageURL == "" {
|
||||
continue
|
||||
}
|
||||
if err := uploader.Delete(slide.ImageURL); err != nil {
|
||||
// 图片可能已过期/不存在/对象已被清理,此时不应阻断“删除任务记录”的主流程。
|
||||
// 这里只记录日志,确保数据库记录被删除后前端认为任务删除成功。
|
||||
logger.Warnf("delete ppt image failed (task_id=%s, url=%s): %v", taskID, slide.ImageURL, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 最后删除任务记录(slides 会随之从数据库消失)。
|
||||
return s.db.Where("task_id = ? AND user_id = ?", taskID, userID).Delete(&model.PPTJob{}).Error
|
||||
}
|
||||
|
||||
// List 返回所有任务列表(从数据库按创建时间倒序),供调用方按用户/状态过滤与分页
|
||||
func (s *PptService) List() []*Task {
|
||||
var jobs []model.PPTJob
|
||||
s.db.Order("created_at DESC").Find(&jobs)
|
||||
tasks := make([]*Task, 0, len(jobs))
|
||||
for i := range jobs {
|
||||
tasks = append(tasks, modelToTask(&jobs[i]))
|
||||
}
|
||||
sort.Slice(tasks, func(i, j int) bool {
|
||||
return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
// ListUserTasks 当前用户的任务分页列表(缺 title/thumb 时补全并写库)
|
||||
func (s *PptService) ListUserTasks(ctx context.Context, userID uint, page, pageSize int) ([]*Task, int) {
|
||||
all := s.List()
|
||||
filtered := make([]*Task, 0, len(all))
|
||||
for _, t := range all {
|
||||
if t.UserID == userID {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
total := len(filtered)
|
||||
start := (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
slice := filtered[start:end]
|
||||
cfg, cfgErr := s.loadPPTConfig()
|
||||
if cfgErr != nil {
|
||||
logger.Warnf("ListUserTasks loadPPTConfig: %v", cfgErr)
|
||||
}
|
||||
if s.llm == nil {
|
||||
s.llm = NewLLMClient()
|
||||
}
|
||||
for _, t := range slice {
|
||||
s.ensureTaskMeta(ctx, t, cfg)
|
||||
}
|
||||
return slice, total
|
||||
}
|
||||
|
||||
// EnsureTaskMeta 对外暴露标题/缩略图补全逻辑,供管理端列表/详情复用。
|
||||
func (s *PptService) EnsureTaskMeta(ctx context.Context, task *Task) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
cfg, cfgErr := s.loadPPTConfig()
|
||||
if cfgErr != nil {
|
||||
logger.Warnf("EnsureTaskMeta loadPPTConfig: %v", cfgErr)
|
||||
}
|
||||
if s.llm == nil {
|
||||
s.llm = NewLLMClient()
|
||||
}
|
||||
s.ensureTaskMeta(ctx, task, cfg)
|
||||
}
|
||||
|
||||
func (s *PptService) ensureTaskMeta(ctx context.Context, task *Task, cfg types.PPTConfig) {
|
||||
updates := map[string]interface{}{}
|
||||
if strings.TrimSpace(task.Title) == "" && strings.TrimSpace(task.Content) != "" {
|
||||
title := "未命名演示文稿"
|
||||
if cfg.OutlineLLMApiURL != "" && cfg.OutlineLLMApiKey != "" {
|
||||
ti, err := s.llm.GeneratePPTTitle(ctx, cfg, task.Content, task.Language)
|
||||
if err != nil {
|
||||
logger.Warnf("ensureTaskMeta GeneratePPTTitle task_id=%s: %v", task.TaskID, err)
|
||||
} else {
|
||||
ti = strings.TrimSpace(ti)
|
||||
if ti != "" {
|
||||
title = truncateTitleRunes(ti, 255)
|
||||
}
|
||||
}
|
||||
}
|
||||
task.Title = title
|
||||
updates["title"] = task.Title
|
||||
}
|
||||
if task.Thumb == "" && len(task.Slides) > 0 {
|
||||
thumb := DerivePPTThumbFromSlides(task.Slides)
|
||||
if thumb != "" {
|
||||
task.Thumb = thumb
|
||||
updates["thumb"] = thumb
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
updates["updated_at"] = time.Now()
|
||||
_ = s.db.Model(&model.PPTJob{}).Where("task_id = ?", task.TaskID).Updates(updates).Error
|
||||
}
|
||||
}
|
||||
|
||||
// ListAdminJobs 管理后台任务列表(筛选 + 分页)
|
||||
func (s *PptService) ListAdminJobs(ctx context.Context, page, pageSize, filterUserID int, status string) ([]*Task, int) {
|
||||
items := s.List()
|
||||
filtered := make([]*Task, 0, len(items))
|
||||
for _, t := range items {
|
||||
if filterUserID > 0 && int(t.UserID) != filterUserID {
|
||||
continue
|
||||
}
|
||||
if status != "" && string(t.Status) != status {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
total := len(filtered)
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
slice := filtered[start:end]
|
||||
for _, t := range slice {
|
||||
s.EnsureTaskMeta(ctx, t)
|
||||
}
|
||||
return slice, total
|
||||
}
|
||||
|
||||
// Stats 任务状态统计(管理后台)
|
||||
func (s *PptService) Stats() (total, completed, processing, failed, pending int64) {
|
||||
for _, t := range s.List() {
|
||||
total++
|
||||
switch t.Status {
|
||||
case TaskStatusCompleted:
|
||||
completed++
|
||||
case TaskStatusProcessing:
|
||||
processing++
|
||||
case TaskStatusFailed:
|
||||
failed++
|
||||
case TaskStatusPending:
|
||||
pending++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// RunTask 执行 PPT 生成:分镜、并发生图、转存图片、扣算力
|
||||
func (s *PptService) RunTask(ctx context.Context, task *Task, cfg types.PPTConfig) {
|
||||
s.UpdateStatus(task.TaskID, TaskStatusProcessing)
|
||||
|
||||
maxPages := task.Pages
|
||||
if maxPages <= 0 {
|
||||
maxPages = cfg.MaxSlidesPerTask
|
||||
}
|
||||
plans, err := s.llm.GenerateSlides(ctx, cfg, task.Content, task.Prompt, task.Language, task.Mode, maxPages)
|
||||
if err != nil {
|
||||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("生成分镜失败:%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(plans) == 0 {
|
||||
s.MarkAsFailed(task.TaskID, "分镜结果为空")
|
||||
return
|
||||
}
|
||||
|
||||
total := len(plans)
|
||||
if cfg.MaxSlidesPerTask > 0 && total > cfg.MaxSlidesPerTask {
|
||||
plans = plans[:cfg.MaxSlidesPerTask]
|
||||
total = len(plans)
|
||||
}
|
||||
|
||||
outlines := slidePlansToOutlines(plans)
|
||||
if err := s.saveSlidesOutline(task.TaskID, total, outlines); err != nil {
|
||||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("保存分镜占位失败:%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
generator, err := NewImageGenerator(cfg)
|
||||
if err != nil {
|
||||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("初始化图片生成器失败:%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.runSlideImageJobs(ctx, task, cfg, generator, outlines); err != nil {
|
||||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("图片生成失败:%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
s.UpdateStatus(task.TaskID, TaskStatusCompleted)
|
||||
}
|
||||
|
||||
func (s *PptService) loadPPTConfig() (types.PPTConfig, error) {
|
||||
var cfgModel model.Config
|
||||
var pptCfg types.PPTConfig
|
||||
|
||||
err := s.db.Where("name", types.ConfigKeyPPT).First(&cfgModel).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
pptCfg.MaxSlidesPerTask = 30
|
||||
pptCfg.MaxConcurrentRequests = 3
|
||||
pptCfg.QPSLimit = 1
|
||||
pptCfg.PowerCostPerSlide = 0
|
||||
return pptCfg, nil
|
||||
}
|
||||
return pptCfg, err
|
||||
}
|
||||
|
||||
err = utils.JsonDecode(cfgModel.Value, &pptCfg)
|
||||
if err != nil {
|
||||
return pptCfg, err
|
||||
}
|
||||
|
||||
legacyMax10 := pptCfg.MaxSlidesPerTask == 10
|
||||
if pptCfg.MaxSlidesPerTask <= 0 {
|
||||
pptCfg.MaxSlidesPerTask = 30
|
||||
}
|
||||
if legacyMax10 {
|
||||
// 与前端 PPT 页数控件 max=30 对齐;历史默认 10 会导致用户选择 12/15 仍被截断为 10
|
||||
pptCfg.MaxSlidesPerTask = 30
|
||||
}
|
||||
if pptCfg.MaxConcurrentRequests <= 0 {
|
||||
pptCfg.MaxConcurrentRequests = 3
|
||||
}
|
||||
if pptCfg.QPSLimit <= 0 {
|
||||
pptCfg.QPSLimit = 1
|
||||
}
|
||||
|
||||
if legacyMax10 {
|
||||
val := utils.JsonEncode(pptCfg)
|
||||
_ = s.db.Model(&model.Config{}).Where("name = ?", types.ConfigKeyPPT).Update("value", val)
|
||||
}
|
||||
|
||||
return pptCfg, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package ppt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PrepareReferenceInputsForImg2Img 将幻灯片参考图转为第三方 API 可消费的输入:本地存储时读文件并转为 data URI(base64),公网 URL 原样传递。
|
||||
func PrepareReferenceInputsForImg2Img(rawURL string, oss types.OSSConfig, app *types.AppConfig) ([]string, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("empty reference image url")
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "data:") {
|
||||
return []string{rawURL}, nil
|
||||
}
|
||||
|
||||
if oss.Active == "local" {
|
||||
if fp, ok := mapLocalUploadFile(rawURL, oss.Local); ok {
|
||||
b, err := os.ReadFile(fp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read local reference image: %w", err)
|
||||
}
|
||||
if _, err := decodeImageBytes(b); err != nil {
|
||||
return nil, fmt.Errorf("reference is not a valid image: %w", err)
|
||||
}
|
||||
mime := http.DetectContentType(b)
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
mime = "image/png"
|
||||
}
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(b))
|
||||
return []string{dataURI}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if u, err := url.Parse(rawURL); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return []string{rawURL}, nil
|
||||
}
|
||||
|
||||
abs := resolveAbsoluteImageURL(rawURL, oss.Local, app)
|
||||
if strings.TrimSpace(abs) == "" {
|
||||
return nil, fmt.Errorf("cannot resolve reference image url")
|
||||
}
|
||||
return []string{abs}, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package ppt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EditSlideImage 基于当前激活图做图生图,追加 image_history 并将 image_url 设为新版。
|
||||
func (s *PptService) EditSlideImage(ctx context.Context, taskID string, userID uint, slideIndex int, prompt string, oss types.OSSConfig, app *types.AppConfig) ([]SlideData, error) {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return nil, fmt.Errorf("请输入修改说明")
|
||||
}
|
||||
task, ok := s.GetTask(taskID)
|
||||
if !ok {
|
||||
return nil, ErrPptTaskNotFound
|
||||
}
|
||||
if task.UserID != userID {
|
||||
return nil, ErrPptTaskNotFound
|
||||
}
|
||||
refURL := ""
|
||||
for _, sl := range task.Slides {
|
||||
if sl.SlideIndex == slideIndex {
|
||||
normalizeSlideImageHistory(&sl)
|
||||
refURL = strings.TrimSpace(sl.ImageURL)
|
||||
break
|
||||
}
|
||||
}
|
||||
if refURL == "" {
|
||||
if slideExists(task.Slides, slideIndex) {
|
||||
return nil, ErrPptSlideNoImage
|
||||
}
|
||||
return nil, ErrPptSlideNotFound
|
||||
}
|
||||
|
||||
cfg, err := s.loadPPTConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
power, err := s.userPower(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.PowerCostPerSlide > 0 && power < cfg.PowerCostPerSlide {
|
||||
return nil, ErrInsufficientPower
|
||||
}
|
||||
|
||||
generator, err := NewImageGenerator(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refInputs, err := PrepareReferenceInputsForImg2Img(refURL, oss, app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("准备参考图失败:%w", err)
|
||||
}
|
||||
|
||||
imgURL, err := generator.GenerateWithReference(ctx, prompt, refInputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(imgURL, ".png", false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("转存图片失败:%w", err)
|
||||
}
|
||||
|
||||
if err := s.applySlideImageEdit(taskID, slideIndex, storedURL, prompt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.PowerCostPerSlide > 0 {
|
||||
err = s.userService.DecreasePower(userID, cfg.PowerCostPerSlide, model.PowerLog{
|
||||
Type: types.PowerConsume,
|
||||
Model: generator.Provider(),
|
||||
Remark: fmt.Sprintf("PPT 任务 %s 第 %d 页图生图编辑", taskID, slideIndex),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扣减算力失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
task2, _ := s.GetTask(taskID)
|
||||
return task2.Slides, nil
|
||||
}
|
||||
|
||||
func slideExists(slides []SlideData, slideIndex int) bool {
|
||||
for _, sl := range slides {
|
||||
if sl.SlideIndex == slideIndex {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *PptService) applySlideImageEdit(taskID string, slideIndex int, newURL string, editPrompt string) error {
|
||||
s.slidesLock.Lock()
|
||||
defer s.slidesLock.Unlock()
|
||||
|
||||
var job model.PPTJob
|
||||
if err := s.db.Where("task_id = ?", taskID).First(&job).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slides := job.Slides
|
||||
found := false
|
||||
for i := range slides {
|
||||
if slides[i].SlideIndex != slideIndex {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
sd := voToSlide(slides[i])
|
||||
normalizeSlideImageHistory(&sd)
|
||||
if strings.TrimSpace(sd.ImageURL) == "" {
|
||||
return ErrPptSlideNoImage
|
||||
}
|
||||
sd.ImageHistory = append(sd.ImageHistory, vo.PPTSlideImageVersion{
|
||||
ImageURL: newURL,
|
||||
Prompt: editPrompt,
|
||||
})
|
||||
sd.ImageURL = newURL
|
||||
slides[i] = slideToVO(sd)
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return ErrPptSlideNotFound
|
||||
}
|
||||
job.Slides = slides
|
||||
biz := voSlidesToBiz(slides)
|
||||
return s.refreshJobMeta(&job, biz)
|
||||
}
|
||||
|
||||
// SetActiveSlideVersion 将 image_url 切换为 image_history[versionIndex]。
|
||||
func (s *PptService) SetActiveSlideVersion(taskID string, userID uint, slideIndex int, versionIndex int) ([]SlideData, error) {
|
||||
task, ok := s.GetTask(taskID)
|
||||
if !ok {
|
||||
return nil, ErrPptTaskNotFound
|
||||
}
|
||||
if task.UserID != userID {
|
||||
return nil, ErrPptTaskNotFound
|
||||
}
|
||||
|
||||
s.slidesLock.Lock()
|
||||
defer s.slidesLock.Unlock()
|
||||
|
||||
var job model.PPTJob
|
||||
if err := s.db.Where("task_id = ?", taskID).First(&job).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slides := job.Slides
|
||||
found := false
|
||||
for i := range slides {
|
||||
if slides[i].SlideIndex != slideIndex {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
sd := voToSlide(slides[i])
|
||||
normalizeSlideImageHistory(&sd)
|
||||
hist := sd.ImageHistory
|
||||
if versionIndex < 0 || versionIndex >= len(hist) {
|
||||
return nil, ErrPptInvalidVersionIndex
|
||||
}
|
||||
sd.ImageURL = hist[versionIndex].ImageURL
|
||||
slides[i] = slideToVO(sd)
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrPptSlideNotFound
|
||||
}
|
||||
|
||||
job.Slides = slides
|
||||
biz := voSlidesToBiz(slides)
|
||||
if err := s.refreshJobMeta(&job, biz); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := voSlidesToBizNormalized(job.Slides)
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
package sd
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
|
||||
// SD 绘画服务
|
||||
|
||||
type Service struct {
|
||||
httpClient *req.Client
|
||||
taskQueue *store.RedisQueue
|
||||
db *gorm.DB
|
||||
uploadManager *oss.UploaderManager
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Client, userService *service.UserService) *Service {
|
||||
return &Service{
|
||||
httpClient: req.C(),
|
||||
taskQueue: store.NewRedisQueue("StableDiffusion_Task_Queue", redisCli),
|
||||
db: db,
|
||||
uploadManager: manager,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的人物加载到队列
|
||||
var jobs []model.SdJob
|
||||
s.db.Where("progress", 0).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.SdTask
|
||||
err := utils.JsonDecode(v.TaskInfo, &task)
|
||||
if err != nil {
|
||||
logger.Errorf("decode task info with error: %v", err)
|
||||
continue
|
||||
}
|
||||
task.Id = int(v.Id)
|
||||
s.PushTask(task)
|
||||
}
|
||||
logger.Infof("Starting Stable-Diffusion job consumer")
|
||||
go func() {
|
||||
for {
|
||||
var task types.SdTask
|
||||
err := s.taskQueue.LPop(&task)
|
||||
if err != nil {
|
||||
logger.Errorf("taking task with error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// translate prompt
|
||||
if utils.HasChinese(task.Params.Prompt) {
|
||||
content, err := utils.OpenAIRequest(s.db, fmt.Sprintf(service.TranslatePromptTemplate, task.Params.Prompt), task.TranslateModelId)
|
||||
if err == nil {
|
||||
task.Params.Prompt = content
|
||||
} else {
|
||||
logger.Warnf("error with translate prompt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// translate negative prompt
|
||||
if task.Params.NegPrompt != "" && utils.HasChinese(task.Params.NegPrompt) {
|
||||
content, err := utils.OpenAIRequest(s.db, fmt.Sprintf(service.TranslatePromptTemplate, task.Params.NegPrompt), task.TranslateModelId)
|
||||
if err == nil {
|
||||
task.Params.NegPrompt = content
|
||||
} else {
|
||||
logger.Warnf("error with translate prompt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("handle a new Stable-Diffusion task: %+v", task)
|
||||
err = s.Txt2Img(task)
|
||||
if err != nil {
|
||||
logger.Error("绘画任务执行失败:", err.Error())
|
||||
// update the task progress
|
||||
s.db.Model(&model.SdJob{Id: uint(task.Id)}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress,
|
||||
"err_msg": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Txt2ImgReq 文生图请求实体
|
||||
type Txt2ImgReq struct {
|
||||
Prompt string `json:"prompt"`
|
||||
NegativePrompt string `json:"negative_prompt"`
|
||||
Seed int64 `json:"seed,omitempty"`
|
||||
Steps int `json:"steps"`
|
||||
CfgScale float32 `json:"cfg_scale"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
SamplerName string `json:"sampler_name"`
|
||||
Scheduler string `json:"scheduler"`
|
||||
EnableHr bool `json:"enable_hr,omitempty"`
|
||||
HrScale int `json:"hr_scale,omitempty"`
|
||||
HrUpscaler string `json:"hr_upscaler,omitempty"`
|
||||
HrSecondPassSteps int `json:"hr_second_pass_steps,omitempty"`
|
||||
DenoisingStrength float32 `json:"denoising_strength,omitempty"`
|
||||
ForceTaskId string `json:"force_task_id,omitempty"`
|
||||
}
|
||||
|
||||
// Txt2ImgResp 文生图响应实体
|
||||
type Txt2ImgResp struct {
|
||||
Images []string `json:"images"`
|
||||
Parameters struct {
|
||||
} `json:"parameters"`
|
||||
Info string `json:"info"`
|
||||
}
|
||||
|
||||
// TaskProgressResp 任务进度响应实体
|
||||
type TaskProgressResp struct {
|
||||
Progress float64 `json:"progress"`
|
||||
EtaRelative float64 `json:"eta_relative"`
|
||||
}
|
||||
|
||||
// Txt2Img 文生图 API
|
||||
func (s *Service) Txt2Img(task types.SdTask) error {
|
||||
body := Txt2ImgReq{
|
||||
Prompt: task.Params.Prompt,
|
||||
NegativePrompt: task.Params.NegPrompt,
|
||||
Steps: task.Params.Steps,
|
||||
CfgScale: task.Params.CfgScale,
|
||||
Width: task.Params.Width,
|
||||
Height: task.Params.Height,
|
||||
SamplerName: task.Params.Sampler,
|
||||
Scheduler: task.Params.Scheduler,
|
||||
ForceTaskId: task.Params.TaskId,
|
||||
}
|
||||
if task.Params.Seed > 0 {
|
||||
body.Seed = task.Params.Seed
|
||||
}
|
||||
if task.Params.HdFix {
|
||||
body.EnableHr = true
|
||||
body.HrScale = task.Params.HdScale
|
||||
body.HrUpscaler = task.Params.HdScaleAlg
|
||||
body.HrSecondPassSteps = task.Params.HdSteps
|
||||
body.DenoisingStrength = task.Params.HdRedrawRate
|
||||
}
|
||||
var res Txt2ImgResp
|
||||
var errChan = make(chan error)
|
||||
|
||||
var apiKey model.ApiKey
|
||||
err := s.db.Where("type", "sd").Where("enabled", true).Order("last_used_at ASC").First(&apiKey).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("no available Stable-Diffusion api key: %v", err)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/sdapi/v1/txt2img", apiKey.ApiURL)
|
||||
logger.Infof("send image request to %s", apiURL)
|
||||
// send a request to sd api endpoint
|
||||
go func() {
|
||||
response, err := s.httpClient.R().
|
||||
SetHeader("Authorization", apiKey.Value).
|
||||
SetBody(body).
|
||||
SetSuccessResult(&res).
|
||||
Post(apiURL)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
if response.IsErrorState() {
|
||||
errChan <- fmt.Errorf("error http code status: %v", response.Status)
|
||||
return
|
||||
}
|
||||
|
||||
// update the last used time
|
||||
apiKey.LastUsedAt = time.Now().Unix()
|
||||
s.db.Updates(&apiKey)
|
||||
|
||||
// 保存 Base64 图片
|
||||
imgURL, err := s.uploadManager.GetUploadHandler().PutBase64(res.Images[0])
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error with upload image: %v", err)
|
||||
return
|
||||
}
|
||||
// 获取绘画真实的 seed
|
||||
var info map[string]interface{}
|
||||
err = utils.JsonDecode(res.Info, &info)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error with decode task response: %v", err)
|
||||
return
|
||||
}
|
||||
task.Params.Seed = int64(utils.IntValue(utils.InterfaceToString(info["seed"]), -1))
|
||||
s.db.Model(&model.SdJob{Id: uint(task.Id)}).UpdateColumns(model.SdJob{ImgURL: imgURL, Params: utils.JsonEncode(task.Params), Prompt: task.Params.Prompt})
|
||||
errChan <- nil
|
||||
}()
|
||||
|
||||
// waiting for task finish
|
||||
for {
|
||||
select {
|
||||
case err := <-errChan:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// task finished
|
||||
s.db.Model(&model.SdJob{Id: uint(task.Id)}).UpdateColumn("progress", 100)
|
||||
return nil
|
||||
default:
|
||||
resp, err := s.checkTaskProgress(apiKey)
|
||||
// 更新任务进度
|
||||
if err == nil && resp.Progress > 0 {
|
||||
s.db.Model(&model.SdJob{Id: uint(task.Id)}).UpdateColumn("progress", int(resp.Progress*100))
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 执行任务
|
||||
func (s *Service) checkTaskProgress(apiKey model.ApiKey) (*TaskProgressResp, error) {
|
||||
apiURL := fmt.Sprintf("%s/sdapi/v1/progress?skip_current_image=false", apiKey.ApiURL)
|
||||
var res TaskProgressResp
|
||||
response, err := s.httpClient.R().
|
||||
SetHeader("Authorization", apiKey.Value).
|
||||
SetSuccessResult(&res).
|
||||
Get(apiURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.IsErrorState() {
|
||||
return nil, fmt.Errorf("error http code status: %v", response.Status)
|
||||
}
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (s *Service) PushTask(task types.SdTask) {
|
||||
logger.Debugf("add a new MidJourney task to the task list: %+v", task)
|
||||
if err := s.taskQueue.RPush(task); err != nil {
|
||||
logger.Errorf("push sd task to queue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckTaskStatus 检查任务状态,自动删除过期或者失败的任务
|
||||
func (s *Service) CheckTaskStatus() {
|
||||
go func() {
|
||||
logger.Info("Running Stable-Diffusion task status checking ...")
|
||||
for {
|
||||
var jobs []model.SdJob
|
||||
res := s.db.Where("progress < ?", 100).Find(&jobs)
|
||||
if res.Error != nil {
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
// 5 分钟还没完成的任务标记为失败
|
||||
if time.Since(job.CreatedAt) > time.Minute*5 {
|
||||
job.Progress = service.FailTaskProgress
|
||||
job.ErrMsg = "任务超时"
|
||||
s.db.Updates(&job)
|
||||
}
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
s.db.Where("progress", service.FailTaskProgress).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: "stable-diffusion",
|
||||
Remark: fmt.Sprintf("任务失败,退回算力。任务ID:%d, Err: %s", job.Id, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// 更新任务状态
|
||||
s.db.Model(&job).UpdateColumn("power", 0)
|
||||
}
|
||||
time.Sleep(time.Second * 5)
|
||||
}
|
||||
}()
|
||||
}
|
||||
+4
-10
@@ -8,14 +8,14 @@ package sms
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/utils"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BaoSmsService struct {
|
||||
@@ -58,15 +58,9 @@ func (s *BaoSmsService) SendVerifyCode(mobile string, code int) error {
|
||||
params.Set("c", content)
|
||||
|
||||
apiURL := fmt.Sprintf("https://%s/sms?%s", s.domain, params.Encode())
|
||||
response, err := http.Get(apiURL)
|
||||
body, status, err := utils.FetchURLBytes(context.Background(), apiURL, "", 30*time.Second, 2, 2<<20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("smsbao request failed: status=%d: %w", status, err)
|
||||
}
|
||||
result := string(body)
|
||||
logger.Debugf("send SmsBao result: %v", errMsg[result])
|
||||
|
||||
@@ -9,6 +9,7 @@ package sms
|
||||
|
||||
const Ali = "aliyun"
|
||||
const Bao = "bao"
|
||||
const Tencent = "tencent"
|
||||
|
||||
type Service interface {
|
||||
SendVerifyCode(mobile string, code int) error
|
||||
|
||||
@@ -9,23 +9,25 @@ package sms
|
||||
|
||||
import (
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
)
|
||||
|
||||
type SmsManager struct {
|
||||
aliyun *AliYunSmsService
|
||||
bao *BaoSmsService
|
||||
active string
|
||||
aliyun *AliYunSmsService
|
||||
bao *BaoSmsService
|
||||
tencent *TencentSmsService
|
||||
active string
|
||||
}
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
func NewSmsManager(sysConfig *types.SystemConfig, aliyun *AliYunSmsService, bao *BaoSmsService) (*SmsManager, error) {
|
||||
func NewSmsManager(sysConfig *types.SystemConfig, aliyun *AliYunSmsService, bao *BaoSmsService, tencent *TencentSmsService) (*SmsManager, error) {
|
||||
|
||||
return &SmsManager{
|
||||
active: sysConfig.SMS.Active,
|
||||
aliyun: aliyun,
|
||||
bao: bao,
|
||||
active: sysConfig.SMS.Active,
|
||||
aliyun: aliyun,
|
||||
bao: bao,
|
||||
tencent: tencent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +37,8 @@ func (m *SmsManager) GetService() Service {
|
||||
return m.aliyun
|
||||
case Bao:
|
||||
return m.bao
|
||||
case Tencent:
|
||||
return m.tencent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -49,6 +53,8 @@ func (m *SmsManager) UpdateConfig(config types.SMSConfig) {
|
||||
m.aliyun.UpdateConfig(config.Ali)
|
||||
case Bao:
|
||||
m.bao.UpdateConfig(config.Bao)
|
||||
case Tencent:
|
||||
m.tencent.UpdateConfig(config.Tencent)
|
||||
}
|
||||
m.active = config.Active
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package sms
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
sms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
|
||||
)
|
||||
|
||||
type TencentSmsService struct {
|
||||
config types.SmsConfigTencent
|
||||
client *sms.Client
|
||||
region string
|
||||
}
|
||||
|
||||
func NewTencentSmsService(sysConfig *types.SystemConfig) (*TencentSmsService, error) {
|
||||
config := sysConfig.SMS.Tencent
|
||||
region := config.Region
|
||||
if region == "" {
|
||||
region = "ap-guangzhou" // 默认使用广州地区
|
||||
}
|
||||
|
||||
s := TencentSmsService{
|
||||
config: config,
|
||||
region: region,
|
||||
}
|
||||
if sysConfig.SMS.Active == Tencent {
|
||||
err := s.UpdateConfig(config)
|
||||
if err != nil {
|
||||
logger.Errorf("腾讯云短信初始化失败: %v", err)
|
||||
}
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (s *TencentSmsService) UpdateConfig(config types.SmsConfigTencent) error {
|
||||
if config.SecretId == "" || config.SecretKey == "" {
|
||||
// 配置不完整时不初始化客户端
|
||||
s.config = config
|
||||
if config.Region != "" {
|
||||
s.region = config.Region
|
||||
} else {
|
||||
s.region = "ap-guangzhou"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
region := config.Region
|
||||
if region == "" {
|
||||
region = "ap-guangzhou"
|
||||
}
|
||||
|
||||
// 创建凭证
|
||||
credential := common.NewCredential(
|
||||
config.SecretId,
|
||||
config.SecretKey,
|
||||
)
|
||||
|
||||
// 创建客户端配置
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.Endpoint = "sms.tencentcloudapi.com"
|
||||
|
||||
// 创建客户端
|
||||
client, err := sms.NewClient(credential, region, cpf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
s.client = client
|
||||
s.config = config
|
||||
s.region = region
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendVerifyCode 发送验证码短信
|
||||
// 注意:腾讯云后台配置的短信模板内容应与配置中的 code_template 一致
|
||||
// 模板只需要1个参数:{1} 表示验证码,例如:{1}为您的验证码,请于5分钟内填写,如非本人操作,请忽略本短信。
|
||||
func (s *TencentSmsService) SendVerifyCode(mobile string, code int) error {
|
||||
if s.client == nil {
|
||||
return fmt.Errorf("腾讯云短信服务未初始化")
|
||||
}
|
||||
|
||||
// 创建发送短信请求
|
||||
request := sms.NewSendSmsRequest()
|
||||
request.SmsSdkAppId = common.StringPtr(s.config.SmsSdkAppId)
|
||||
request.SignName = common.StringPtr(s.config.Sign)
|
||||
request.TemplateId = common.StringPtr(s.config.CodeTempId)
|
||||
request.PhoneNumberSet = common.StringPtrs([]string{mobile})
|
||||
request.TemplateParamSet = common.StringPtrs([]string{fmt.Sprintf("%d", code), "5"})
|
||||
|
||||
// 发送短信
|
||||
response, err := s.client.SendSms(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send SMS: %v", err)
|
||||
}
|
||||
|
||||
// 检查响应
|
||||
if response.Response == nil {
|
||||
return fmt.Errorf("failed to send SMS: response is nil")
|
||||
}
|
||||
|
||||
if len(response.Response.SendStatusSet) == 0 {
|
||||
return fmt.Errorf("failed to send SMS: no send status")
|
||||
}
|
||||
|
||||
sendStatus := response.Response.SendStatusSet[0]
|
||||
if sendStatus.Code == nil || *sendStatus.Code != "Ok" {
|
||||
message := "unknown error"
|
||||
if sendStatus.Message != nil {
|
||||
message = *sendStatus.Message
|
||||
}
|
||||
return fmt.Errorf("failed to send SMS: %s", message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ Service = &TencentSmsService{}
|
||||
@@ -120,7 +120,7 @@ func (s *SmtpService) sendTLS(auth smtp.Auth, to string, subject string, body st
|
||||
}
|
||||
_, _ = fmt.Fprintln(wc)
|
||||
// 将邮件内容写入
|
||||
_, err = fmt.Fprintf(wc, body)
|
||||
_, err = fmt.Fprint(wc, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending email: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
package sora
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"geekai/service/oss"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
type SoraService struct {
|
||||
uploadManager *oss.UploaderManager
|
||||
@@ -34,15 +33,10 @@ func (s *SoraService) DownloadVideoURL(text string) (*vo.File, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取 JSON 数据
|
||||
resp, err := http.Get(videoDataURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
// 用统一的超时/重试策略,避免“偶发 HTTPS 握手超时”直接导致失败
|
||||
body, _, err := utils.FetchURLBytes(context.Background(), videoDataURL, "", 30*time.Second, 2, 2<<20)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to get video data: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -50,13 +44,14 @@ func (s *SoraService) DownloadVideoURL(text string) (*vo.File, error) {
|
||||
var videoData map[string]any
|
||||
err = json.Unmarshal(body, &videoData)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to unmarshal video data: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if v, ok := videoData["url"].(string); ok && v != "" {
|
||||
logger.Infof("try to download video: %s", v)
|
||||
videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v, ".mp4", true)
|
||||
if err != nil {
|
||||
if err != nil { // 如果上传失败,则返回原始错误
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+36
-16
@@ -12,11 +12,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"io"
|
||||
"time"
|
||||
@@ -27,7 +28,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
type Service struct {
|
||||
httpClient *req.Client
|
||||
@@ -61,13 +62,24 @@ func (s *Service) Run() {
|
||||
var jobs []model.SunoJob
|
||||
s.db.Where("task_id", "").Where("progress", 0).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.SunoTask
|
||||
err := utils.JsonDecode(v.TaskInfo, &task)
|
||||
if err != nil {
|
||||
logger.Errorf("decode task info with error: %v", err)
|
||||
continue
|
||||
// 从 Params 中提取字段构建 task
|
||||
task := types.SunoTask{
|
||||
Id: v.Id,
|
||||
UserId: int(v.UserId),
|
||||
Channel: v.Channel,
|
||||
Type: v.Type,
|
||||
Title: v.Title,
|
||||
RefTaskId: v.RefTaskId,
|
||||
RefSongId: v.RefSongId,
|
||||
Prompt: v.Params.Prompt,
|
||||
Lyrics: v.Params.Lyrics,
|
||||
Tags: v.Params.Tags,
|
||||
Model: v.Params.Model,
|
||||
Instrumental: v.Params.Instrumental,
|
||||
ExtendSecs: v.Params.ExtendSecs,
|
||||
SongId: v.SongId,
|
||||
AudioURL: v.AudioURL,
|
||||
}
|
||||
task.Id = v.Id
|
||||
s.PushTask(task)
|
||||
}
|
||||
logger.Info("Starting Suno job consumer...")
|
||||
@@ -335,15 +347,22 @@ func (s *Service) SyncTaskProgress() {
|
||||
job.SongId = v.Id
|
||||
job.Duration = int(v.Metadata.Duration)
|
||||
job.Prompt = v.Metadata.Prompt
|
||||
|
||||
// 设置 Params
|
||||
tags := v.Metadata.Tags
|
||||
// 修复 tags 字段过长导致插入数据库失败
|
||||
if len(v.Metadata.Tags) > 255 {
|
||||
job.Tags = v.Metadata.Tags[:255]
|
||||
} else {
|
||||
job.Tags = v.Metadata.Tags
|
||||
if len(tags) > 255 {
|
||||
tags = tags[:255]
|
||||
}
|
||||
job.Params = vo.SunoParam{
|
||||
Prompt: v.Metadata.Prompt,
|
||||
Tags: tags,
|
||||
Model: v.ModelName,
|
||||
Instrumental: job.Params.Instrumental, // 保持原任务参数
|
||||
ExtendSecs: job.Params.ExtendSecs, // 保持原任务参数
|
||||
}
|
||||
|
||||
job.ModelName = v.ModelName
|
||||
job.RawData = utils.JsonEncode(v)
|
||||
job.Output = utils.JsonEncode(v)
|
||||
job.CoverURL = v.ImageLargeUrl
|
||||
job.AudioURL = v.AudioUrl
|
||||
|
||||
@@ -372,11 +391,12 @@ func (s *Service) SyncTaskProgress() {
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
s.db.Where("progress", service.FailTaskProgress).Where("power > ?", 0).Find(&jobs)
|
||||
s.db.Select("id", "user_id", "power", "task_id", "err_msg", "params").
|
||||
Where("progress", service.FailTaskProgress).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: job.ModelName,
|
||||
Model: job.Params.Model,
|
||||
Remark: fmt.Sprintf("Suno 任务失败,退回算力。任务ID:%s,Err:%s", job.TaskId, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package service
|
||||
|
||||
import logger2 "geekai/logger"
|
||||
import "geekai/log"
|
||||
|
||||
const FailTaskProgress = 101
|
||||
const (
|
||||
@@ -17,7 +17,7 @@ type NotifyMessage struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
const TranslatePromptTemplate = "Translate the following painting prompt words into English keyword phrases. Without any explanation, directly output the keyword phrases separated by commas. The content to be translated is: [%s]"
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package adapters
|
||||
|
||||
import "geekai/log"
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
var logger = log.GetLogger()
|
||||
|
||||
// CreateTaskResponse 创建任务响应
|
||||
type CreateTaskResponse struct {
|
||||
TaskId string `json:"task_id"` // 任务ID
|
||||
Channel string `json:"channel"` // 渠道标识
|
||||
Prompt string `json:"prompt"` // 优化后的提示词(如果有)
|
||||
State string `json:"state"` // 任务状态
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// QueryTaskResponse 查询任务响应
|
||||
type QueryTaskResponse struct {
|
||||
TaskId string `json:"task_id"` // 任务ID
|
||||
Status string `json:"status"` // 任务状态
|
||||
Progress int `json:"progress"` // 进度(0-100)
|
||||
VideoURL string `json:"video_url"` // 视频URL
|
||||
Prompt string `json:"prompt"` // 提示词
|
||||
ErrMsg string `json:"err_msg"` // 错误信息
|
||||
StatusMsg string `json:"status_msg"` // 状态消息
|
||||
Output string `json:"output"` // 任务输出的原始信息(JSON字符串)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DoubaoAdapter 豆包 Seedance 视频生成适配器(通过 Kapon VolcArk 接入)
|
||||
type DoubaoAdapter struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDoubaoAdapter 创建 Doubao 适配器
|
||||
func NewDoubaoAdapter(db *gorm.DB) *DoubaoAdapter {
|
||||
return &DoubaoAdapter{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *DoubaoAdapter) GetProvider() string {
|
||||
return types.VideoDoubao
|
||||
}
|
||||
|
||||
// doubaoContentItem 请求体中的 content 子项
|
||||
type doubaoContentItem struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL map[string]string `json:"image_url,omitempty"`
|
||||
Extra map[string]interface{} `json:"extra,omitempty"` // 预留扩展
|
||||
}
|
||||
|
||||
// doubaoCreateRequest 创建任务请求体
|
||||
type doubaoCreateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Content []doubaoContentItem `json:"content"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Frames int `json:"frames,omitempty"`
|
||||
Ratio string `json:"ratio,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
Seed int64 `json:"seed,omitempty"`
|
||||
// 其他官方支持的字段,按需追加
|
||||
}
|
||||
|
||||
// doubaoCreateResponse 创建任务响应
|
||||
type doubaoCreateResponse struct {
|
||||
Id string `json:"id"`
|
||||
PlatformId string `json:"platform_id"`
|
||||
// 其余字段目前用不到,先不展开
|
||||
}
|
||||
|
||||
// doubaoQueryContent 查询任务 content 字段
|
||||
type doubaoQueryContent struct {
|
||||
VideoURL string `json:"video_url"`
|
||||
LastFrameURL string `json:"last_frame_url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// doubaoQueryUsage 查询任务 usage 字段
|
||||
type doubaoQueryUsage struct {
|
||||
VideoTokens int `json:"video_tokens"`
|
||||
}
|
||||
|
||||
// doubaoQueryResponse 查询任务响应
|
||||
type doubaoQueryResponse struct {
|
||||
Id string `json:"id"`
|
||||
PlatformId string `json:"platform_id"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Content doubaoQueryContent `json:"content"`
|
||||
Duration int `json:"duration"`
|
||||
Frames int `json:"framespersecond"`
|
||||
Usage doubaoQueryUsage `json:"usage"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CreateTask 创建豆包 Seedance 视频任务
|
||||
func (a *DoubaoAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
if videoConfig == nil {
|
||||
return CreateTaskResponse{}, errors.New("视频配置为空")
|
||||
}
|
||||
if videoConfig.ApiURL == "" || videoConfig.ApiKey == "" {
|
||||
return CreateTaskResponse{}, errors.New("豆包视频未配置 ApiURL 或 ApiKey")
|
||||
}
|
||||
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, errors.New("invalid params type for Doubao video task")
|
||||
}
|
||||
|
||||
// 模型名称:优先从 params.model 读取,否则默认 doubao-seedance-1-5-pro
|
||||
modelName := "doubao-seedance-1-5-pro"
|
||||
if v, ok := paramsMap["model"].(string); ok && v != "" {
|
||||
modelName = v
|
||||
}
|
||||
|
||||
// 解析基础参数
|
||||
duration := 0
|
||||
if v, ok := paramsMap["duration"].(float64); ok {
|
||||
duration = int(v)
|
||||
}
|
||||
if v, ok := paramsMap["duration"].(int); ok {
|
||||
duration = v
|
||||
}
|
||||
|
||||
ratio := ""
|
||||
if v, ok := paramsMap["aspect_ratio"].(string); ok {
|
||||
ratio = v
|
||||
}
|
||||
|
||||
resolution := ""
|
||||
if v, ok := paramsMap["resolution"].(string); ok {
|
||||
resolution = v
|
||||
}
|
||||
|
||||
var seed int64
|
||||
switch v := paramsMap["seed"].(type) {
|
||||
case float64:
|
||||
seed = int64(v)
|
||||
case int:
|
||||
seed = int64(v)
|
||||
case int64:
|
||||
seed = v
|
||||
}
|
||||
|
||||
// 构建 content 数组:文本提示词为必填
|
||||
content := []doubaoContentItem{
|
||||
{
|
||||
Type: "text",
|
||||
Text: task.Prompt,
|
||||
},
|
||||
}
|
||||
|
||||
// 如果存在 input_reference(图片 URL),则追加 image_url 项,用于 I2V
|
||||
if ref, ok := paramsMap["input_reference"].(string); ok && ref != "" {
|
||||
content = append(content, doubaoContentItem{
|
||||
Type: "image_url",
|
||||
ImageURL: map[string]string{
|
||||
"url": ref,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
reqBody := doubaoCreateRequest{
|
||||
Model: modelName,
|
||||
Content: content,
|
||||
Duration: duration,
|
||||
Ratio: ratio,
|
||||
Resolution: resolution,
|
||||
Seed: seed,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("序列化豆包请求失败: %v", err)
|
||||
}
|
||||
logger.Debugf("DoubaoCreateRequest: %s", string(payload))
|
||||
|
||||
url := fmt.Sprintf("%s/seedance/v3/contents/generations/tasks", videoConfig.ApiURL)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("创建豆包请求失败: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("调用豆包接口失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("读取豆包响应失败: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return CreateTaskResponse{}, fmt.Errorf("豆包接口返回错误状态码: %d, %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResp doubaoCreateResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析豆包创建任务响应失败: %v, body=%s", err, string(body))
|
||||
}
|
||||
|
||||
taskId := apiResp.PlatformId
|
||||
if taskId == "" {
|
||||
taskId = apiResp.Id
|
||||
}
|
||||
if taskId == "" {
|
||||
return CreateTaskResponse{}, fmt.Errorf("豆包创建任务响应缺少任务 ID, body=%s", string(body))
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: taskId,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询豆包 Seedance 视频任务状态
|
||||
func (a *DoubaoAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
if videoConfig == nil {
|
||||
return QueryTaskResponse{}, errors.New("视频配置为空")
|
||||
}
|
||||
if videoConfig.ApiURL == "" || videoConfig.ApiKey == "" {
|
||||
return QueryTaskResponse{}, errors.New("豆包视频未配置 ApiURL 或 ApiKey")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/seedance/v3/contents/generations/tasks/%s", videoConfig.ApiURL, taskId)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("创建查询请求失败: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("调用豆包查询接口失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("读取豆包查询响应失败: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return QueryTaskResponse{}, fmt.Errorf("豆包查询接口返回错误状态码: %d, %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResp doubaoQueryResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析豆包查询任务响应失败: %v, body=%s", err, string(body))
|
||||
}
|
||||
|
||||
status := apiResp.Status
|
||||
progress := 0
|
||||
|
||||
switch status {
|
||||
case "queued":
|
||||
status = types.VideoStatusPending
|
||||
progress = 10
|
||||
case "running":
|
||||
status = types.VideoStatusInProgress
|
||||
progress = 60
|
||||
case "succeeded":
|
||||
status = types.VideoStatusSuccess
|
||||
progress = 100
|
||||
case "failed", "cancelled":
|
||||
status = types.VideoStatusFailed
|
||||
default:
|
||||
// 保持原样或视为 pending
|
||||
status = types.VideoStatusPending
|
||||
}
|
||||
|
||||
errMsg := apiResp.Error
|
||||
if errMsg == "" && status == types.VideoStatusFailed {
|
||||
errMsg = "doubao task failed"
|
||||
}
|
||||
|
||||
result := QueryTaskResponse{
|
||||
TaskId: apiResp.PlatformId,
|
||||
Status: status,
|
||||
Progress: progress,
|
||||
VideoURL: apiResp.Content.VideoURL,
|
||||
Prompt: "",
|
||||
ErrMsg: errMsg,
|
||||
StatusMsg: status,
|
||||
Output: string(body),
|
||||
}
|
||||
|
||||
if result.TaskId == "" {
|
||||
result.TaskId = apiResp.Id
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// KelingAdapter 可灵视频生成适配器
|
||||
type KelingAdapter struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewKelingAdapter 创建可灵适配器
|
||||
func NewKelingAdapter(db *gorm.DB) *KelingAdapter {
|
||||
return &KelingAdapter{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *KelingAdapter) GetProvider() string {
|
||||
return "keling"
|
||||
}
|
||||
|
||||
// KelingCreateRequest 可灵创建任务请求
|
||||
type KelingCreateRequest struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Prompt string `json:"prompt"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
CfgScale float64 `json:"cfg_scale,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
Sound bool `json:"sound,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
ImageTail string `json:"image_tail,omitempty"`
|
||||
}
|
||||
|
||||
// KelingCreateResponse 可灵创建任务响应
|
||||
type KelingCreateResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// KelingQueryResponse 可灵查询任务响应
|
||||
type KelingQueryResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
TaskResult struct {
|
||||
Images []struct {
|
||||
Index int `json:"index"`
|
||||
URL string `json:"url"`
|
||||
} `json:"images,omitempty"`
|
||||
Videos []struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"videos,omitempty"`
|
||||
} `json:"task_result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *KelingAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, errors.New("invalid params type for KeLing video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
payload := KelingCreateRequest{
|
||||
Prompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if modelName, ok := paramsMap["model_name"].(string); ok {
|
||||
payload.ModelName = modelName
|
||||
}
|
||||
if prompt, ok := paramsMap["prompt"].(string); ok {
|
||||
payload.Prompt = prompt
|
||||
}
|
||||
if negativePrompt, ok := paramsMap["negative_prompt"].(string); ok {
|
||||
payload.NegativePrompt = negativePrompt
|
||||
}
|
||||
if cfgScale, ok := paramsMap["cfg_scale"].(float64); ok {
|
||||
payload.CfgScale = cfgScale
|
||||
}
|
||||
if mode, ok := paramsMap["mode"].(string); ok {
|
||||
payload.Mode = mode
|
||||
}
|
||||
if aspectRatio, ok := paramsMap["aspect_ratio"].(string); ok {
|
||||
payload.AspectRatio = aspectRatio
|
||||
}
|
||||
if duration, ok := paramsMap["duration"].(string); ok {
|
||||
payload.Duration = duration
|
||||
}
|
||||
|
||||
if sound, ok := paramsMap["sound"].(bool); ok {
|
||||
payload.Sound = sound
|
||||
}
|
||||
|
||||
// 处理图生视频
|
||||
taskType, ok := paramsMap["task_type"].(string)
|
||||
if ok && taskType == "image2video" {
|
||||
if image, ok := paramsMap["image"].(string); ok {
|
||||
payload.Image = image
|
||||
}
|
||||
if imageTail, ok := paramsMap["image_tail"].(string); ok {
|
||||
payload.ImageTail = imageTail
|
||||
}
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to marshal payload: %v", err)
|
||||
}
|
||||
logger.Debugf("KelingCreateRequest: %+v", string(jsonPayload))
|
||||
|
||||
// 发送请求
|
||||
url := fmt.Sprintf("%s/kling/v1/videos/%s", videoConfig.ApiURL, taskType)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonPayload))
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{Timeout: time.Duration(30) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 处理响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return CreateTaskResponse{}, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResponse KelingCreateResponse
|
||||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if apiResponse.Code != 0 {
|
||||
return CreateTaskResponse{}, fmt.Errorf("API error: %s", apiResponse.Message)
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: apiResponse.Data.TaskID,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *KelingAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
// 从 taskId 中提取 action(可灵的 taskId 格式可能包含 action 信息)
|
||||
// 这里需要从任务信息中获取 task_type,暂时使用 text2video 作为默认值
|
||||
action := "text2video"
|
||||
|
||||
// 尝试从 channel 或其他地方获取 action,这里简化处理
|
||||
// 实际应该从任务信息中获取
|
||||
|
||||
url := fmt.Sprintf("%s/kling/v1/videos/%s/%s", videoConfig.ApiURL, action, taskId)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(30) * time.Second}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var response KelingQueryResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return QueryTaskResponse{}, fmt.Errorf("API error: %s", response.Message)
|
||||
}
|
||||
|
||||
// 转换状态
|
||||
state := response.Data.TaskStatus
|
||||
status := state
|
||||
switch state {
|
||||
case "in_progress", "processing":
|
||||
status = types.VideoStatusInProgress
|
||||
case "completed", "succeed", "success":
|
||||
status = types.VideoStatusSuccess
|
||||
case "failed":
|
||||
status = types.VideoStatusFailed
|
||||
default:
|
||||
status = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
result := QueryTaskResponse{
|
||||
TaskId: response.Data.TaskID,
|
||||
Status: status,
|
||||
ErrMsg: response.Data.TaskStatusMsg,
|
||||
StatusMsg: response.Data.TaskStatusMsg,
|
||||
Output: string(body),
|
||||
}
|
||||
|
||||
// 提取视频URL
|
||||
if len(response.Data.TaskResult.Videos) > 0 {
|
||||
result.VideoURL = response.Data.TaskResult.Videos[0].URL
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LumaAdapter Luma 视频生成适配器
|
||||
type LumaAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewLumaAdapter 创建 Luma 适配器
|
||||
func NewLumaAdapter(db *gorm.DB) *LumaAdapter {
|
||||
return &LumaAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *LumaAdapter) GetProvider() string {
|
||||
return "luma"
|
||||
}
|
||||
|
||||
// LumaCreateRequest Luma 创建任务请求
|
||||
type LumaCreateRequest struct {
|
||||
ModelName string `json:"model_name"`
|
||||
UserPrompt string `json:"user_prompt"`
|
||||
ExpandPrompt bool `json:"expand_prompt,omitempty"`
|
||||
Loop bool `json:"loop,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"` // 图生视频
|
||||
ImageEndURL string `json:"image_end_url,omitempty"` // 图生视频
|
||||
Duration string `json:"duration,omitempty"` // 视频时长
|
||||
Resolution string `json:"resolution,omitempty"` // 视频分辨率
|
||||
}
|
||||
|
||||
// LumaCreateResponse Luma 创建任务响应
|
||||
type LumaCreateResponse struct {
|
||||
Id string `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
State string `json:"state"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// LumaQueryResponse Luma 查询任务响应
|
||||
type LumaQueryResponse struct {
|
||||
Id string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Video struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
} `json:"video"`
|
||||
Prompt string `json:"prompt"`
|
||||
Thumbnail struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"thumbnail"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *LumaAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]any)
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, errors.New("invalid params type for Luma video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
reqBody := LumaCreateRequest{
|
||||
UserPrompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if expandPrompt, ok := paramsMap["expand_prompt"].(bool); ok {
|
||||
reqBody.ExpandPrompt = expandPrompt
|
||||
}
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
reqBody.ModelName = model
|
||||
}
|
||||
if loop, ok := paramsMap["loop"].(bool); ok {
|
||||
reqBody.Loop = loop
|
||||
}
|
||||
if imageURL, ok := paramsMap["image_url"].(string); ok {
|
||||
reqBody.ImageURL = imageURL
|
||||
}
|
||||
if imageEndURL, ok := paramsMap["image_end_url"].(string); ok {
|
||||
reqBody.ImageEndURL = imageEndURL
|
||||
}
|
||||
if duration, ok := paramsMap["duration"].(string); ok {
|
||||
reqBody.Duration = duration
|
||||
}
|
||||
if resolution, ok := paramsMap["resolution"].(string); ok {
|
||||
reqBody.Resolution = resolution
|
||||
}
|
||||
// 发送请求
|
||||
apiURL := fmt.Sprintf("%s/luma/generations", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res LumaCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.Id,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: res.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: res.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *LumaAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/luma/generations/%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res LumaQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
switch res.State {
|
||||
case "completed", "succeed", "success":
|
||||
res.State = types.VideoStatusSuccess
|
||||
case "in_progress", "running":
|
||||
res.State = types.VideoStatusInProgress
|
||||
case "failed":
|
||||
res.State = types.VideoStatusFailed
|
||||
default:
|
||||
res.State = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.Id,
|
||||
Status: res.State,
|
||||
VideoURL: res.Video.DownloadURL,
|
||||
Prompt: res.Prompt,
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MiniMaxAdapter MiniMax 视频生成适配器
|
||||
type MiniMaxAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewMiniMaxAdapter 创建 MiniMax 适配器
|
||||
func NewMiniMaxAdapter(db *gorm.DB) *MiniMaxAdapter {
|
||||
return &MiniMaxAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *MiniMaxAdapter) GetProvider() string {
|
||||
return "minimax"
|
||||
}
|
||||
|
||||
// MiniMaxCreateRequest MiniMax 创建任务请求
|
||||
type MiniMaxCreateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
FirstFrameImage string `json:"first_frame_image,omitempty"`
|
||||
LastFrameImage string `json:"last_frame_image,omitempty"`
|
||||
PromptOptimizer bool `json:"prompt_optimizer,omitempty"`
|
||||
}
|
||||
|
||||
// MiniMaxCreateResponse MiniMax 创建任务响应
|
||||
type MiniMaxCreateResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
BaseResp struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
} `json:"base_resp"`
|
||||
}
|
||||
|
||||
// MiniMaxFile MiniMax 文件信息
|
||||
type MiniMaxFile struct {
|
||||
Bytes int `json:"bytes"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
FileId int64 `json:"file_id"`
|
||||
Filename string `json:"filename"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
// MiniMaxQueryResponse MiniMax 查询任务响应
|
||||
type MiniMaxQueryResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
FileId string `json:"file_id,omitempty"` // 顶层 file_id 可能是字符串
|
||||
File *MiniMaxFile `json:"file,omitempty"` // file 对象包含详细信息
|
||||
VideoWidth int `json:"video_width,omitempty"`
|
||||
VideoHeight int `json:"video_height,omitempty"`
|
||||
VideoURL string `json:"video_url,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
ErrMsg string `json:"err_msg,omitempty"`
|
||||
StatusMsg string `json:"status_msg,omitempty"`
|
||||
BaseResp struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
} `json:"base_resp"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *MiniMaxAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, fmt.Errorf("invalid params type for MiniMax video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
reqBody := MiniMaxCreateRequest{
|
||||
Prompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
reqBody.Model = model
|
||||
}
|
||||
if duration, ok := paramsMap["duration"].(float64); ok {
|
||||
reqBody.Duration = int(duration)
|
||||
} else if duration, ok := paramsMap["duration"].(int); ok {
|
||||
reqBody.Duration = duration
|
||||
}
|
||||
if resolution, ok := paramsMap["resolution"].(string); ok {
|
||||
reqBody.Resolution = resolution
|
||||
}
|
||||
if firstFrameImage, ok := paramsMap["first_frame_image"].(string); ok {
|
||||
reqBody.FirstFrameImage = firstFrameImage
|
||||
}
|
||||
if lastFrameImage, ok := paramsMap["last_frame_image"].(string); ok {
|
||||
reqBody.LastFrameImage = lastFrameImage
|
||||
}
|
||||
if promptOptimizer, ok := paramsMap["prompt_optimizer"].(bool); ok {
|
||||
reqBody.PromptOptimizer = promptOptimizer
|
||||
} else {
|
||||
reqBody.PromptOptimizer = true // 默认值
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
apiURL := fmt.Sprintf("%s/minimax/v1/video_generation", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res MiniMaxCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
if res.BaseResp.StatusCode != 0 {
|
||||
return CreateTaskResponse{}, fmt.Errorf("API 返回错误:%s", res.BaseResp.StatusMsg)
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *MiniMaxAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
// MiniMax 查询接口
|
||||
apiURL := fmt.Sprintf("%s/minimax/v1/query/video_generation?task_id=%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res MiniMaxQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
if res.BaseResp.StatusCode != 0 {
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回错误:%s", res.BaseResp.StatusMsg)
|
||||
}
|
||||
|
||||
// 转换状态(处理大小写)
|
||||
state := strings.ToLower(res.Status)
|
||||
switch state {
|
||||
case "completed", "succeed", "success":
|
||||
state = types.VideoStatusSuccess
|
||||
case "in_progress", "running":
|
||||
state = types.VideoStatusInProgress
|
||||
case "failed":
|
||||
state = types.VideoStatusFailed
|
||||
default:
|
||||
state = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 获取视频URL,优先从 file.download_url 获取
|
||||
videoURL := res.VideoURL
|
||||
if res.File != nil && res.File.DownloadURL != "" {
|
||||
videoURL = res.File.DownloadURL
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Status: state,
|
||||
VideoURL: videoURL,
|
||||
Prompt: res.Prompt,
|
||||
ErrMsg: res.ErrMsg,
|
||||
StatusMsg: res.StatusMsg,
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"geekai/utils"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SoraAdapter Sora 视频生成适配器
|
||||
type SoraAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewSoraAdapter 创建 Sora 适配器
|
||||
func NewSoraAdapter(db *gorm.DB) *SoraAdapter {
|
||||
return &SoraAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *SoraAdapter) GetProvider() string {
|
||||
return "sora"
|
||||
}
|
||||
|
||||
// SoraCreateRequest Sora 创建任务请求
|
||||
type SoraCreateRequest struct {
|
||||
Model string `json:"model"` // 模型名称:sora-2, sora-2-pro
|
||||
Prompt string `json:"prompt"` // 提示词
|
||||
Size string `json:"size,omitempty"` // 分辨率:1280x720, 720x1280, 1792x1024, 1024x1792
|
||||
InputReference interface{} `json:"input_reference,omitempty"` // 图生视频的参考图片,官方为对象 {"image_url": "..."},也兼容字符串 URL
|
||||
Seconds string `json:"seconds,omitempty"` // 视频时长(秒),默认4秒
|
||||
Watermark bool `json:"watermark,omitempty"` // 是否添加水印
|
||||
}
|
||||
|
||||
// SoraCreateResponse Sora 创建任务响应
|
||||
type SoraCreateResponse struct {
|
||||
ID string `json:"id"` // 任务ID
|
||||
Object string `json:"object"` // 对象类型,固定为 "video"
|
||||
Model string `json:"model"` // 模型名称
|
||||
Status string `json:"status"` // 状态:queued, in_progress, completed, failed
|
||||
CreatedAt int64 `json:"created_at"` // 创建时间戳
|
||||
Seconds string `json:"seconds"` // 视频时长
|
||||
Size string `json:"size"` // 分辨率
|
||||
Error *SoraError `json:"error,omitempty"` // 错误信息(成功时为null)
|
||||
}
|
||||
|
||||
// SoraQueryResponse Sora 查询任务响应
|
||||
type SoraQueryResponse struct {
|
||||
ID string `json:"id"` // 任务ID
|
||||
Object string `json:"object"` // 对象类型,固定为 "video"
|
||||
Model string `json:"model"` // 模型名称
|
||||
Status string `json:"status"` // 状态:queued, in_progress, completed, failed
|
||||
Progress int `json:"progress"` // 进度(0-100)
|
||||
CreatedAt int64 `json:"created_at"` // 创建时间戳
|
||||
Seconds string `json:"seconds"` // 视频时长
|
||||
Size string `json:"size"` // 分辨率
|
||||
Error *SoraError `json:"error,omitempty"` // 错误信息(成功时为null)
|
||||
VideoURL string `json:"video_url"` // 视频URL(成功时生成)
|
||||
}
|
||||
|
||||
type SoraError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *SoraAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]any)
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, fmt.Errorf("invalid params type for Sora video task")
|
||||
}
|
||||
|
||||
// 是否调用官方 Sora 接口
|
||||
isOfficial := false
|
||||
if v, ok := paramsMap["is_official"].(bool); ok {
|
||||
isOfficial = v
|
||||
}
|
||||
|
||||
// 提取通用参数
|
||||
model, ok := paramsMap["model"].(string)
|
||||
if !ok || model == "" {
|
||||
return CreateTaskResponse{}, fmt.Errorf("model 参数必填")
|
||||
}
|
||||
|
||||
size, _ := paramsMap["size"].(string)
|
||||
|
||||
seconds := "10" // 默认 10 秒
|
||||
if v, ok := paramsMap["seconds"].(string); ok && v != "" {
|
||||
seconds = v
|
||||
} else if duration, ok := paramsMap["duration"].(float64); ok {
|
||||
seconds = fmt.Sprintf("%.0f", duration)
|
||||
} else if duration, ok := paramsMap["duration"].(int); ok {
|
||||
seconds = fmt.Sprintf("%d", duration)
|
||||
}
|
||||
|
||||
watermark := false
|
||||
if v, ok := paramsMap["watermark"].(bool); ok {
|
||||
watermark = v
|
||||
}
|
||||
|
||||
// 处理图生视频(input_reference 参数)
|
||||
// 支持单个字符串或数组的第一个元素
|
||||
var imageURL string
|
||||
if inputRef, ok := paramsMap["input_reference"].(string); ok && inputRef != "" {
|
||||
imageURL = inputRef
|
||||
} else if images, ok := paramsMap["images"].([]interface{}); ok && len(images) > 0 {
|
||||
// 兼容旧的 images 参数格式
|
||||
if imgStr, ok := images[0].(string); ok && imgStr != "" {
|
||||
imageURL = imgStr
|
||||
}
|
||||
} else if image, ok := paramsMap["image"].(string); ok && image != "" {
|
||||
// 兼容 image 参数
|
||||
imageURL = image
|
||||
}
|
||||
|
||||
// 官方 Sora:使用 multipart/form-data 携带文件
|
||||
if isOfficial && imageURL != "" {
|
||||
return a.createOfficialSoraTask(task, videoConfig, model, size, seconds, imageURL)
|
||||
}
|
||||
|
||||
// 其他场景:保持原来的 JSON 调用,input_reference 继续传 URL 字符串
|
||||
reqBody := SoraCreateRequest{
|
||||
Model: model,
|
||||
Prompt: task.Prompt,
|
||||
Size: size,
|
||||
Seconds: seconds,
|
||||
Watermark: watermark,
|
||||
}
|
||||
|
||||
if imageURL != "" {
|
||||
reqBody.InputReference = imageURL
|
||||
}
|
||||
|
||||
// 发送 JSON 请求
|
||||
apiURL := fmt.Sprintf("%s/v1/videos", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res SoraCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// 转换状态:queued -> pending
|
||||
state := res.Status
|
||||
if state == "queued" || state == "in_progress" || state == "" {
|
||||
state = "pending"
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.ID,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: state,
|
||||
CreatedAt: time.Unix(res.CreatedAt, 0).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createOfficialSoraTask 调用官方 Sora API,使用 multipart/form-data 携带图片文件
|
||||
func (a *SoraAdapter) createOfficialSoraTask(task types.VideoTask, videoConfig *types.VideoConfig, model, size, seconds, imageURL string) (CreateTaskResponse, error) {
|
||||
if videoConfig == nil || videoConfig.ApiURL == "" || videoConfig.ApiKey == "" {
|
||||
return CreateTaskResponse{}, fmt.Errorf("Sora 视频配置不完整")
|
||||
}
|
||||
|
||||
imgData, err := downloadImageBytes(imageURL)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("下载参考图片失败:%v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
// 文本字段
|
||||
if err = writer.WriteField("prompt", task.Prompt); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
if err = writer.WriteField("model", model); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
if size != "" {
|
||||
if err = writer.WriteField("size", size); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
}
|
||||
if seconds != "" {
|
||||
if err = writer.WriteField("seconds", seconds); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// 文件字段
|
||||
fileWriter, err := writer.CreateFormFile("input_reference", "image")
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
if _, err = fileWriter.Write(imgData); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
|
||||
if err = writer.Close(); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/v1/videos", videoConfig.ApiURL)
|
||||
req, err := http.NewRequest(http.MethodPost, apiURL, &buf)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
client := &http.Client{Timeout: 3 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求官方 Sora API 出错:%v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求官方 Sora API 出错:%d, %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res SoraCreateResponse
|
||||
if err = json.Unmarshal(body, &res); err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析官方 Sora API 数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
state := res.Status
|
||||
if state == "queued" || state == "in_progress" || state == "" {
|
||||
state = "pending"
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.ID,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: state,
|
||||
CreatedAt: time.Unix(res.CreatedAt, 0).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// downloadImageBytes 下载远程图片并返回二进制内容,用于 multipart 文件上传
|
||||
func downloadImageBytes(imageURL string) ([]byte, error) {
|
||||
body, _, err := utils.FetchURLBytes(context.Background(), imageURL, "", 3*time.Minute, 2, 32<<20)
|
||||
return body, err
|
||||
}
|
||||
|
||||
// downloadImageAsDataURL 下载远程图片并转为 data URL,避免向官方 Sora 直接传地址
|
||||
// QueryTask 查询任务状态
|
||||
func (a *SoraAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/v1/videos/%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res SoraQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// 转换状态:queued -> pending, completed -> success
|
||||
state := res.Status
|
||||
switch state {
|
||||
case "completed", "succeed", "success":
|
||||
state = types.VideoStatusSuccess
|
||||
case "in_progress", "running":
|
||||
state = types.VideoStatusInProgress
|
||||
case "failed":
|
||||
state = types.VideoStatusFailed
|
||||
default:
|
||||
state = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 处理错误信息
|
||||
errMsg := ""
|
||||
if res.Error != nil {
|
||||
errMsg = res.Error.Message
|
||||
} else {
|
||||
errMsg = fmt.Sprintf("进度: %d%%", res.Progress)
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.ID,
|
||||
Status: state,
|
||||
Progress: res.Progress,
|
||||
VideoURL: res.VideoURL,
|
||||
Prompt: "", // Sora API 响应中不包含 prompt 字段
|
||||
ErrMsg: errMsg,
|
||||
StatusMsg: fmt.Sprintf("进度: %d%%", res.Progress),
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VideoAdapter 视频生成适配器接口
|
||||
type VideoAdapter interface {
|
||||
// CreateTask 创建视频生成任务
|
||||
CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error)
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error)
|
||||
|
||||
// GetProvider 获取服务提供商名称(不带版本号:veo, sora, luma)
|
||||
GetProvider() string
|
||||
}
|
||||
|
||||
// VeoAdapter Veo 视频生成适配器
|
||||
type VeoAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewVeoAdapter 创建 Veo 适配器
|
||||
func NewVeoAdapter(db *gorm.DB) *VeoAdapter {
|
||||
return &VeoAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *VeoAdapter) GetProvider() string {
|
||||
return "veo"
|
||||
}
|
||||
|
||||
// VeoCreateRequest Veo 创建任务请求
|
||||
type VeoCreateRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model"`
|
||||
EnhancePrompt bool `json:"enhance_prompt,omitempty"`
|
||||
EnableUpsample bool `json:"enable_upsample,omitempty"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
Images []string `json:"images,omitempty"` // 图生视频时使用
|
||||
}
|
||||
|
||||
// VeoCreateResponse Veo 创建任务响应
|
||||
type VeoCreateResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
}
|
||||
|
||||
// VeoQueryResponse Veo 查询任务响应
|
||||
type VeoQueryResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
FailReason string `json:"fail_reason"`
|
||||
SubmitTime int64 `json:"submit_time"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
FinishTime int64 `json:"finish_time"`
|
||||
Progress string `json:"progress"`
|
||||
Data VeoQueryData `json:"data"`
|
||||
SearchItem string `json:"search_item"`
|
||||
}
|
||||
|
||||
// VeoQueryData Veo 查询响应中的 data 字段
|
||||
type VeoQueryData struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *VeoAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]any)
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, fmt.Errorf("invalid params type for Veo video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
reqBody := VeoCreateRequest{
|
||||
Prompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
reqBody.Model = model
|
||||
}
|
||||
if enhancePrompt, ok := paramsMap["enhance_prompt"].(bool); ok {
|
||||
reqBody.EnhancePrompt = enhancePrompt
|
||||
}
|
||||
if enableUpsample, ok := paramsMap["enable_upsample"].(bool); ok {
|
||||
reqBody.EnableUpsample = enableUpsample
|
||||
}
|
||||
if aspectRatio, ok := paramsMap["aspect_ratio"].(string); ok {
|
||||
reqBody.AspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
// 处理图生视频(images 参数)
|
||||
if images, ok := paramsMap["images"].([]interface{}); ok {
|
||||
imageUrls := make([]string, 0)
|
||||
for _, img := range images {
|
||||
if imgStr, ok := img.(string); ok {
|
||||
imageUrls = append(imageUrls, imgStr)
|
||||
}
|
||||
}
|
||||
if len(imageUrls) > 0 {
|
||||
reqBody.Images = imageUrls
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
apiURL := fmt.Sprintf("%s/v2/videos/generations", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res VeoCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *VeoAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/v2/videos/generations/%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res VeoQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// 转换状态(SUCCESS -> success, FAILED -> failed, 其他保持原样)
|
||||
state := strings.ToLower(res.Status)
|
||||
switch state {
|
||||
case "in_progress", "running":
|
||||
state = types.VideoStatusInProgress
|
||||
case "completed", "succeed", "success":
|
||||
state = types.VideoStatusSuccess
|
||||
case "failed":
|
||||
state = types.VideoStatusFailed
|
||||
default:
|
||||
state = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 解析进度(从 "100%" 转换为 100)
|
||||
progress := 0
|
||||
if res.Progress != "" {
|
||||
// 移除 % 符号并转换为整数
|
||||
progressStr := strings.TrimSuffix(res.Progress, "%")
|
||||
if p, err := fmt.Sscanf(progressStr, "%d", &progress); err == nil && p == 1 {
|
||||
// 成功解析
|
||||
}
|
||||
}
|
||||
|
||||
// 从 data.output 中提取视频 URL
|
||||
videoURL := res.Data.Output
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Status: state,
|
||||
Progress: progress,
|
||||
VideoURL: videoURL,
|
||||
ErrMsg: res.FailReason,
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WanAdapter Wan(通义万相)视频生成适配器
|
||||
type WanAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewWanAdapter 创建 Wan 适配器
|
||||
func NewWanAdapter(db *gorm.DB) *WanAdapter {
|
||||
return &WanAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *WanAdapter) GetProvider() string {
|
||||
return "wan"
|
||||
}
|
||||
|
||||
// WanCreateRequest Wan 创建任务请求
|
||||
type WanCreateRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
PromptExtend bool `json:"prompt_extend,omitempty"`
|
||||
}
|
||||
|
||||
// WanCreateResponse Wan 创建任务响应
|
||||
type WanCreateResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
}
|
||||
|
||||
// WanQueryResponse Wan 查询任务响应
|
||||
type WanQueryResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
FailReason string `json:"fail_reason"`
|
||||
SubmitTime int64 `json:"submit_time"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
FinishTime int64 `json:"finish_time"`
|
||||
Progress string `json:"progress"`
|
||||
Data WanQueryData `json:"data"`
|
||||
SearchItem string `json:"search_item"`
|
||||
}
|
||||
|
||||
// WanQueryData Wan 查询响应中的 data 字段
|
||||
type WanQueryData struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *WanAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]any)
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, fmt.Errorf("invalid params type for Wan video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
reqBody := WanCreateRequest{
|
||||
Prompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
reqBody.Model = model
|
||||
}
|
||||
if duration, ok := paramsMap["duration"].(float64); ok {
|
||||
reqBody.Duration = int(duration)
|
||||
} else if duration, ok := paramsMap["duration"].(int); ok {
|
||||
reqBody.Duration = duration
|
||||
}
|
||||
if resolution, ok := paramsMap["resolution"].(string); ok {
|
||||
reqBody.Resolution = resolution
|
||||
}
|
||||
if images, ok := paramsMap["images"].([]any); ok {
|
||||
imageUrls := make([]string, 0)
|
||||
for _, img := range images {
|
||||
if imgStr, ok := img.(string); ok {
|
||||
imageUrls = append(imageUrls, imgStr)
|
||||
}
|
||||
}
|
||||
if len(imageUrls) > 0 {
|
||||
reqBody.Images = imageUrls
|
||||
}
|
||||
}
|
||||
if negativePrompt, ok := paramsMap["negative_prompt"].(string); ok {
|
||||
reqBody.NegativePrompt = negativePrompt
|
||||
}
|
||||
if promptExtend, ok := paramsMap["prompt_extend"].(bool); ok {
|
||||
reqBody.PromptExtend = promptExtend
|
||||
}
|
||||
|
||||
logger.Debugf("WanCreateRequest: %+v", reqBody)
|
||||
|
||||
// 发送请求
|
||||
apiURL := fmt.Sprintf("%s/v2/videos/generations", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res WanCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: "pending",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *WanAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/v2/videos/generations/%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res WanQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// 转换状态(SUCCESS -> success, FAILED -> failed, 其他保持原样)
|
||||
state := strings.ToLower(res.Status)
|
||||
switch state {
|
||||
case "in_progress", "running":
|
||||
state = types.VideoStatusInProgress
|
||||
case "completed", "succeed", "success":
|
||||
state = types.VideoStatusSuccess
|
||||
case "failed", "failure":
|
||||
state = types.VideoStatusFailed
|
||||
default:
|
||||
state = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 解析进度(从 "100%" 转换为 100)
|
||||
progress := 0
|
||||
if res.Progress != "" {
|
||||
// 移除 % 符号并转换为整数
|
||||
progressStr := strings.TrimSuffix(res.Progress, "%")
|
||||
if p, err := fmt.Sscanf(progressStr, "%d", &progress); err == nil && p == 1 {
|
||||
// 成功解析
|
||||
}
|
||||
}
|
||||
|
||||
// 从 data.output 中提取视频 URL
|
||||
videoURL := res.Data.Output
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Status: state,
|
||||
Progress: progress,
|
||||
VideoURL: videoURL,
|
||||
ErrMsg: res.FailReason,
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package video
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetVideoConfig 从数据库获取视频配置
|
||||
func GetVideoConfig(db *gorm.DB) (*types.VideoConfig, error) {
|
||||
var config model.Config
|
||||
err := db.Where("name", types.ConfigKeyVideo).First(&config).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New("视频配置不存在,请在管理后台配置")
|
||||
}
|
||||
return nil, fmt.Errorf("获取视频配置失败: %v", err)
|
||||
}
|
||||
|
||||
var videoConfig types.VideoConfig
|
||||
err = utils.JsonDecode(config.Value, &videoConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析视频配置失败: %v", err)
|
||||
}
|
||||
|
||||
return &videoConfig, nil
|
||||
}
|
||||
|
||||
// GetModelPowerConfig 获取指定模型的算力配置
|
||||
func GetModelPowerConfig(db *gorm.DB, modelKey string) (*types.VideoModelPower, error) {
|
||||
config, err := GetVideoConfig(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modelPower, ok := config.VideoPowers[modelKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("模型 %s 的算力配置不存在", modelKey)
|
||||
}
|
||||
|
||||
return &modelPower, nil
|
||||
}
|
||||
|
||||
// CalculatePower 根据 modelKey 和 priceKey 计算算力
|
||||
// modelKey: 模型标识(如 "veo-2.0", "sora-2.0")
|
||||
// priceKey: 价格键(如 "fixed", "5_720P", "std_5_sound" 等)
|
||||
func CalculatePower(db *gorm.DB, modelKey string, priceKey string) (int, error) {
|
||||
if priceKey == "" {
|
||||
return 0, errors.New("priceKey 不能为空")
|
||||
}
|
||||
|
||||
modelPower, err := GetModelPowerConfig(db, modelKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
power, ok := modelPower.PowerConfig[priceKey]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("模型 %s 的价格配置 %s 不存在", modelKey, priceKey)
|
||||
}
|
||||
|
||||
if power <= 0 {
|
||||
return 0, fmt.Errorf("模型 %s 的价格配置 %s 的值无效", modelKey, priceKey)
|
||||
}
|
||||
|
||||
return power, nil
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
package video
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
|
||||
type Service struct {
|
||||
httpClient *req.Client
|
||||
db *gorm.DB
|
||||
uploadManager *oss.UploaderManager
|
||||
taskQueue *store.RedisQueue
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Client, userService *service.UserService) *Service {
|
||||
return &Service{
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
db: db,
|
||||
taskQueue: store.NewRedisQueue("Video_Task_Queue", redisCli),
|
||||
uploadManager: manager,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) PushTask(task types.VideoTask) {
|
||||
logger.Infof("add a new Video task to the task list: %+v", task)
|
||||
if err := s.taskQueue.RPush(task); err != nil {
|
||||
logger.Errorf("push video task to queue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的任务加载到队列
|
||||
var jobs []model.VideoJob
|
||||
s.db.Where("task_id", "").Where("progress", 0).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.VideoTask
|
||||
err := utils.JsonDecode(v.TaskInfo, &task)
|
||||
if err != nil {
|
||||
logger.Errorf("decode task info with error: %v", err)
|
||||
continue
|
||||
}
|
||||
task.Id = v.Id
|
||||
s.PushTask(task)
|
||||
}
|
||||
logger.Info("Starting Video job consumer...")
|
||||
go func() {
|
||||
for {
|
||||
var task types.VideoTask
|
||||
err := s.taskQueue.LPop(&task)
|
||||
if err != nil {
|
||||
logger.Errorf("taking task with error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if task.Type == types.VideoLuma {
|
||||
// translate prompt
|
||||
if utils.HasChinese(task.Prompt) {
|
||||
content, err := utils.OpenAIRequest(s.db, fmt.Sprintf(service.TranslatePromptTemplate, task.Prompt), task.TranslateModelId)
|
||||
if err == nil {
|
||||
task.Prompt = content
|
||||
} else {
|
||||
logger.Warnf("error with translate prompt: %v", err)
|
||||
}
|
||||
}
|
||||
var r LumaRespVo
|
||||
r, err = s.LumaCreate(task)
|
||||
if err != nil {
|
||||
logger.Errorf("create task with error: %v", err)
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"err_msg": err.Error(),
|
||||
"progress": service.FailTaskProgress,
|
||||
"cover_url": "/images/failed.jpg",
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新任务信息
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"task_id": r.Id,
|
||||
"channel": r.Channel,
|
||||
"prompt_ext": r.Prompt,
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
s.PushTask(task)
|
||||
}
|
||||
} else if task.Type == types.VideoKeLing {
|
||||
var r KeLingRespVo
|
||||
r, err = s.KeLingCreate(task)
|
||||
logger.Debugf("ke ling create task result: %+v", r)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("create task with error: %v", err)
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"err_msg": err.Error(),
|
||||
"progress": service.FailTaskProgress,
|
||||
"cover_url": "/images/failed.jpg",
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新任务信息
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"task_id": r.Data.TaskID,
|
||||
"channel": r.Channel,
|
||||
"prompt_ext": task.Prompt,
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
s.PushTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) DownloadFiles() {
|
||||
go func() {
|
||||
var items []model.VideoJob
|
||||
for {
|
||||
res := s.db.Where("progress", 102).Find(&items)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range items {
|
||||
if v.WaterURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Infof("try download video: %s", v.WaterURL)
|
||||
videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.WaterURL, ".mp4", true)
|
||||
if err != nil {
|
||||
logger.Errorf("download video with error: %v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("download video success: %s", videoURL)
|
||||
v.WaterURL = videoURL
|
||||
|
||||
if v.VideoURL != "" {
|
||||
logger.Infof("try download no water video: %s", v.VideoURL)
|
||||
videoURL, err = s.uploadManager.GetUploadHandler().PutUrlFile(v.VideoURL, ".mp4", true)
|
||||
if err != nil {
|
||||
logger.Errorf("download video with error: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
logger.Infof("download no water video success: %s", videoURL)
|
||||
v.VideoURL = videoURL
|
||||
v.Progress = 100
|
||||
s.db.Updates(&v)
|
||||
|
||||
// Convert TaskInfo to VideoTask
|
||||
var videoTask types.VideoTask
|
||||
if err := json.Unmarshal([]byte(v.TaskInfo), &videoTask); err != nil {
|
||||
logger.Errorf("failed to unmarshal task info to VideoTask: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SyncTaskProgress 异步拉取任务
|
||||
func (s *Service) SyncTaskProgress() {
|
||||
go func() {
|
||||
var jobs []model.VideoJob
|
||||
for {
|
||||
res := s.db.Where("progress < ?", 100).Where("task_id <> ?", "").Find(&jobs)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
if job.Type == types.VideoLuma {
|
||||
task, err := s.QueryLumaTask(job.TaskId, job.Channel)
|
||||
if err != nil {
|
||||
logger.Errorf("query task with error: %v", err)
|
||||
// 更新任务信息
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress, // 102 表示资源未下载完成,
|
||||
"err_msg": err.Error(),
|
||||
"cover_url": "/images/failed.jpg",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debugf("task: %+v", task)
|
||||
if task.State == "completed" { // 更新任务信息
|
||||
data := map[string]interface{}{
|
||||
"progress": 102, // 102 表示资源未下载完成,
|
||||
"water_url": task.Video.Url,
|
||||
"raw_data": utils.JsonEncode(task),
|
||||
"prompt_ext": task.Prompt,
|
||||
"cover_url": task.Thumbnail.Url,
|
||||
}
|
||||
if task.Video.DownloadUrl != "" {
|
||||
data["video_url"] = task.Video.DownloadUrl
|
||||
}
|
||||
err = s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(data).Error
|
||||
if err != nil {
|
||||
logger.Errorf("更新数据库失败:%v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else if job.Type == types.VideoKeLing {
|
||||
// Convert TaskInfo to VideoTask
|
||||
var videoTask types.VideoTask
|
||||
if err := json.Unmarshal([]byte(job.TaskInfo), &videoTask); err != nil {
|
||||
logger.Errorf("failed to unmarshal task info to VideoTask: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Type assert task.Params to KeLingVideoParams
|
||||
paramsMap, ok := videoTask.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert map to KeLingVideoParams
|
||||
paramsBytes, err := json.Marshal(paramsMap)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var params types.KeLingVideoParams
|
||||
if err := json.Unmarshal(paramsBytes, ¶ms); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
task, err := s.QueryKeLingTask(job.TaskId, job.Channel, params.TaskType)
|
||||
if err != nil {
|
||||
logger.Errorf("query task with error: %v", err)
|
||||
// 更新任务信息
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress, // 102 表示资源未下载完成,
|
||||
"err_msg": err.Error(),
|
||||
"cover_url": "/images/failed.jpg",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debugf("task: %+v", task)
|
||||
if task.TaskStatus == "succeed" { // 更新任务信息
|
||||
data := map[string]interface{}{
|
||||
"progress": 102, // 102 表示资源未下载完成,
|
||||
"water_url": task.TaskResult.Videos[0].URL,
|
||||
"raw_data": utils.JsonEncode(task),
|
||||
"prompt_ext": job.Prompt,
|
||||
"cover_url": "",
|
||||
}
|
||||
if len(task.TaskResult.Videos) > 0 {
|
||||
data["video_url"] = task.TaskResult.Videos[0].URL
|
||||
}
|
||||
err = s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(data).Error
|
||||
if err != nil {
|
||||
logger.Errorf("更新数据库失败:%v", err)
|
||||
continue
|
||||
}
|
||||
} else if task.TaskStatus == "failed" {
|
||||
// 更新任务信息
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress,
|
||||
"err_msg": task.TaskStatusMsg,
|
||||
"cover_url": "/images/failed.jpg",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
s.db.Where("progress", service.FailTaskProgress).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: job.Type,
|
||||
Remark: fmt.Sprintf("%s 任务失败,退回算力。任务ID:%s,Err:%s", job.Type, job.TaskId, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// 更新任务状态
|
||||
s.db.Model(&job).UpdateColumn("power", 0)
|
||||
}
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type LumaTaskVo struct {
|
||||
Id string `json:"id"`
|
||||
Liked interface{} `json:"liked"`
|
||||
State string `json:"state"`
|
||||
Video struct {
|
||||
Url string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
DownloadUrl string `json:"download_url"`
|
||||
} `json:"video"`
|
||||
Prompt string `json:"prompt"`
|
||||
UserId string `json:"user_id"`
|
||||
BatchId string `json:"batch_id"`
|
||||
Thumbnail struct {
|
||||
Url string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"thumbnail"`
|
||||
VideoRaw struct {
|
||||
Url string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"video_raw"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastFrame struct {
|
||||
Url string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"last_frame"`
|
||||
}
|
||||
|
||||
type LumaRespVo struct {
|
||||
Id string `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
State string `json:"state"`
|
||||
QueueState interface{} `json:"queue_state"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Video interface{} `json:"video"`
|
||||
VideoRaw interface{} `json:"video_raw"`
|
||||
Liked interface{} `json:"liked"`
|
||||
EstimateWaitSeconds interface{} `json:"estimate_wait_seconds"`
|
||||
Thumbnail interface{} `json:"thumbnail"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) LumaCreate(task types.VideoTask) (LumaRespVo, error) {
|
||||
// 读取 API KEY
|
||||
var apiKey model.ApiKey
|
||||
session := s.db.Session(&gorm.Session{}).Where("type", "luma").Where("enabled", true)
|
||||
if task.Channel != "" {
|
||||
session = session.Where("api_url", task.Channel)
|
||||
}
|
||||
tx := session.Order("last_used_at DESC").First(&apiKey)
|
||||
if tx.Error != nil {
|
||||
return LumaRespVo{}, errors.New("no available API KEY for Luma")
|
||||
}
|
||||
|
||||
// Type assert task.Params to LumaVideoParams
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return LumaRespVo{}, errors.New("invalid params type for Luma video task")
|
||||
}
|
||||
|
||||
// Convert map to LumaVideoParams
|
||||
paramsBytes, err := json.Marshal(paramsMap)
|
||||
if err != nil {
|
||||
return LumaRespVo{}, fmt.Errorf("failed to marshal params: %v", err)
|
||||
}
|
||||
|
||||
var params types.LumaVideoParams
|
||||
if err := json.Unmarshal(paramsBytes, ¶ms); err != nil {
|
||||
return LumaRespVo{}, fmt.Errorf("failed to unmarshal params: %v", err)
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"user_prompt": task.Prompt,
|
||||
"expand_prompt": params.PromptOptimize,
|
||||
"loop": params.Loop,
|
||||
"image_url": params.StartImgURL, // 图生视频
|
||||
"image_end_url": params.EndImgURL, // 图生视频
|
||||
}
|
||||
|
||||
var res LumaRespVo
|
||||
apiURL := fmt.Sprintf("%s/luma/generations", apiKey.ApiURL)
|
||||
logger.Debugf("API URL: %s, request body: %+v", apiURL, reqBody)
|
||||
r, err := req.C().R().
|
||||
SetHeader("Authorization", "Bearer "+apiKey.Value).
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
if err != nil {
|
||||
return LumaRespVo{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
return LumaRespVo{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, r.String())
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return LumaRespVo{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// update the last_use_at for api key
|
||||
apiKey.LastUsedAt = time.Now().Unix()
|
||||
session.Updates(&apiKey)
|
||||
res.Channel = apiKey.ApiURL
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryLumaTask(taskId string, channel string) (LumaTaskVo, error) {
|
||||
// 读取 API KEY
|
||||
var apiKey model.ApiKey
|
||||
err := s.db.Session(&gorm.Session{}).Where("type", "luma").
|
||||
Where("api_url", channel).
|
||||
Where("enabled", true).
|
||||
Order("last_used_at DESC").First(&apiKey).Error
|
||||
if err != nil {
|
||||
return LumaTaskVo{}, errors.New("no available API KEY for Luma")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/luma/generations/%s", apiKey.ApiURL, taskId)
|
||||
var res LumaTaskVo
|
||||
r, err := req.C().R().SetHeader("Authorization", "Bearer "+apiKey.Value).Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return LumaTaskVo{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
return LumaTaskVo{}, fmt.Errorf("API 返回失败:%v", r.String())
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return LumaTaskVo{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type KeLingRespVo struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
} `json:"data"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) KeLingCreate(task types.VideoTask) (KeLingRespVo, error) {
|
||||
var apiKey model.ApiKey
|
||||
session := s.db.Session(&gorm.Session{}).Where("type", "keling").Where("enabled", true)
|
||||
if task.Channel != "" {
|
||||
session = session.Where("api_url", task.Channel)
|
||||
}
|
||||
tx := session.Order("last_used_at DESC").First(&apiKey)
|
||||
if tx.Error != nil {
|
||||
return KeLingRespVo{}, errors.New("no available API KEY for keling")
|
||||
}
|
||||
|
||||
// Type assert task.Params to KeLingVideoParams
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return KeLingRespVo{}, errors.New("invalid params type for KeLing video task")
|
||||
}
|
||||
|
||||
// Convert map to KeLingVideoParams
|
||||
paramsBytes, err := json.Marshal(paramsMap)
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to marshal params: %v", err)
|
||||
}
|
||||
|
||||
var params types.KeLingVideoParams
|
||||
if err := json.Unmarshal(paramsBytes, ¶ms); err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to unmarshal params: %v", err)
|
||||
}
|
||||
|
||||
// 2. 构建API请求参数
|
||||
payload := map[string]interface{}{
|
||||
"model_name": params.Model,
|
||||
"prompt": task.Prompt,
|
||||
"negative_prompt": params.NegPrompt,
|
||||
"cfg_scale": params.CfgScale,
|
||||
"mode": params.Mode,
|
||||
"aspect_ratio": params.AspectRatio,
|
||||
"duration": params.Duration,
|
||||
}
|
||||
|
||||
// 只有当 CameraControl 的类型不为空时,才处理摄像机控制参数
|
||||
if params.CameraControl.Type != "" {
|
||||
cameraControl := map[string]interface{}{
|
||||
"type": params.CameraControl.Type,
|
||||
}
|
||||
|
||||
// 只有在 simple 类型时才添加 config 参数
|
||||
if params.CameraControl.Type == "simple" {
|
||||
cameraControl["config"] = params.CameraControl.Config
|
||||
}
|
||||
|
||||
payload["camera_control"] = cameraControl
|
||||
}
|
||||
|
||||
// 处理图生视频
|
||||
if params.TaskType == "image2video" {
|
||||
payload["image"] = params.Image
|
||||
payload["image_tail"] = params.ImageTail
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to marshal payload: %v", err)
|
||||
}
|
||||
|
||||
// 3. 准备HTTP请求
|
||||
url := fmt.Sprintf("%s/kling/v1/videos/%s", apiKey.ApiURL, params.TaskType)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonPayload))
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey.Value)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 4. 发送请求
|
||||
client := &http.Client{Timeout: time.Duration(30) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 5. 处理响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return KeLingRespVo{}, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResponse = KeLingRespVo{}
|
||||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
// 设置 API 通道
|
||||
apiResponse.Channel = apiKey.ApiURL
|
||||
return apiResponse, nil
|
||||
}
|
||||
|
||||
// VideoCallbackData 表示视频生成任务的回调数据
|
||||
type VideoCallbackData struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
TaskResult TaskResult `json:"task_result"`
|
||||
}
|
||||
|
||||
type TaskResult struct {
|
||||
Images []CallBackImageResult `json:"images,omitempty"`
|
||||
Videos []CallBackVideoResult `json:"videos,omitempty"`
|
||||
}
|
||||
|
||||
type CallBackImageResult struct {
|
||||
Index int `json:"index"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type CallBackVideoResult struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
func (s *Service) QueryKeLingTask(taskId string, channel string, action string) (VideoCallbackData, error) {
|
||||
var apiKey model.ApiKey
|
||||
err := s.db.Session(&gorm.Session{}).Where("type", "keling").
|
||||
//Where("api_url", channel).
|
||||
Where("enabled", true).
|
||||
Order("last_used_at DESC").First(&apiKey).Error
|
||||
if err != nil {
|
||||
return VideoCallbackData{}, errors.New("no available API KEY for keling")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/kling/v1/videos/%s/%s", apiKey.ApiURL, action, taskId)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return VideoCallbackData{}, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey.Value)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return VideoCallbackData{}, fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return VideoCallbackData{}, fmt.Errorf("unexpected status code: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return VideoCallbackData{}, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data VideoCallbackData `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return VideoCallbackData{}, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return VideoCallbackData{}, fmt.Errorf("API error: %s", response.Message)
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package video
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/log"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/service/video/adapters"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = log.GetLogger()
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
uploadManager *oss.UploaderManager
|
||||
taskQueue *store.RedisQueue
|
||||
userService *service.UserService
|
||||
adapters map[string]adapters.VideoAdapter // provider -> adapter
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Client, userService *service.UserService) *Service {
|
||||
service := &Service{
|
||||
db: db,
|
||||
taskQueue: store.NewRedisQueue("Video_Task_Queue", redisCli),
|
||||
uploadManager: manager,
|
||||
userService: userService,
|
||||
adapters: make(map[string]VideoAdapter),
|
||||
}
|
||||
|
||||
// 注册所有适配器
|
||||
service.registerAdapters()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// VideoAdapter 类型别名,指向 adapters.VideoAdapter
|
||||
type VideoAdapter = adapters.VideoAdapter
|
||||
|
||||
// registerAdapters 注册所有视频生成适配器
|
||||
func (s *Service) registerAdapters() {
|
||||
// 注册 Veo 适配器
|
||||
veoAdapter := adapters.NewVeoAdapter(s.db)
|
||||
s.adapters[veoAdapter.GetProvider()] = veoAdapter
|
||||
|
||||
// 注册 Sora 适配器
|
||||
soraAdapter := adapters.NewSoraAdapter(s.db)
|
||||
s.adapters[soraAdapter.GetProvider()] = soraAdapter
|
||||
|
||||
// 注册 Luma 适配器
|
||||
lumaAdapter := adapters.NewLumaAdapter(s.db)
|
||||
s.adapters[lumaAdapter.GetProvider()] = lumaAdapter
|
||||
|
||||
// 注册可灵适配器
|
||||
kelingAdapter := adapters.NewKelingAdapter(s.db)
|
||||
s.adapters[kelingAdapter.GetProvider()] = kelingAdapter
|
||||
|
||||
// 注册 MiniMax 适配器
|
||||
minimaxAdapter := adapters.NewMiniMaxAdapter(s.db)
|
||||
s.adapters[minimaxAdapter.GetProvider()] = minimaxAdapter
|
||||
|
||||
// 注册 Wan 适配器
|
||||
wanAdapter := adapters.NewWanAdapter(s.db)
|
||||
s.adapters[wanAdapter.GetProvider()] = wanAdapter
|
||||
|
||||
// 注册 Doubao 适配器
|
||||
doubaoAdapter := adapters.NewDoubaoAdapter(s.db)
|
||||
s.adapters[doubaoAdapter.GetProvider()] = doubaoAdapter
|
||||
}
|
||||
|
||||
// getAdapter 获取指定 provider 的适配器
|
||||
func (s *Service) getAdapter(provider string) (adapters.VideoAdapter, error) {
|
||||
adapter, ok := s.adapters[provider]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("不支持的视频生成服务提供商: %s", provider)
|
||||
}
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
// getVideoConfig 获取视频配置
|
||||
func (s *Service) getVideoConfig() (*types.VideoConfig, error) {
|
||||
return GetVideoConfig(s.db)
|
||||
}
|
||||
|
||||
// CreateTask 统一的创建任务方法
|
||||
func (s *Service) CreateTask(task types.VideoTask) (adapters.CreateTaskResponse, error) {
|
||||
// 获取适配器
|
||||
adapter, err := s.getAdapter(task.Type)
|
||||
if err != nil {
|
||||
return adapters.CreateTaskResponse{}, err
|
||||
}
|
||||
|
||||
// 获取视频配置
|
||||
videoConfig, err := s.getVideoConfig()
|
||||
if err != nil {
|
||||
return adapters.CreateTaskResponse{}, err
|
||||
}
|
||||
|
||||
// 调用适配器创建任务
|
||||
return adapter.CreateTask(task, videoConfig)
|
||||
}
|
||||
|
||||
// QueryTask 统一的查询任务方法
|
||||
func (s *Service) QueryTask(provider string, taskId string, channel string, modelKey string) (adapters.QueryTaskResponse, error) {
|
||||
// 获取适配器
|
||||
adapter, err := s.getAdapter(provider)
|
||||
if err != nil {
|
||||
return adapters.QueryTaskResponse{}, err
|
||||
}
|
||||
|
||||
// 获取视频配置
|
||||
videoConfig, err := s.getVideoConfig()
|
||||
if err != nil {
|
||||
return adapters.QueryTaskResponse{}, err
|
||||
}
|
||||
|
||||
// 调用适配器查询任务
|
||||
return adapter.QueryTask(taskId, channel, videoConfig)
|
||||
}
|
||||
|
||||
func (s *Service) PushTask(task types.VideoTask) {
|
||||
logger.Infof("[video] push task to queue jobId=%d type=%s", task.Id, task.Type)
|
||||
if err := s.taskQueue.RPush(task); err != nil {
|
||||
logger.Errorf("[video] push task to queue failed jobId=%d: %v", task.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的任务加载到队列
|
||||
var jobs []model.VideoJob
|
||||
s.db.Where("task_id", "").Where("progress", 0).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.VideoTask
|
||||
err := utils.JsonDecode(v.Params, &task)
|
||||
if err != nil {
|
||||
logger.Errorf("decode task info with error: %v", err)
|
||||
continue
|
||||
}
|
||||
task.Id = v.Id
|
||||
s.PushTask(task)
|
||||
}
|
||||
logger.Infof("[video] job consumer started, loaded %d pending jobs from DB", len(jobs))
|
||||
go func() {
|
||||
for {
|
||||
var task types.VideoTask
|
||||
err := s.taskQueue.LPop(&task)
|
||||
if err != nil {
|
||||
logger.Errorf("taking task with error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debugf("[video] submitting task jobId=%d type=%s prompt=%q", task.Id, task.Type, task.Prompt)
|
||||
r, err := s.CreateTask(task)
|
||||
if err != nil {
|
||||
logger.Errorf("[video] submit failed jobId=%d type=%s: %v", task.Id, task.Type, err)
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"err_msg": err.Error(),
|
||||
"status": types.VideoStatusFailed,
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Infof("[video] submit success jobId=%d type=%s taskId=%s channel=%s", task.Id, task.Type, r.TaskId, r.Channel)
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"task_id": r.TaskId,
|
||||
"channel": r.Channel,
|
||||
"status": types.VideoStatusPending,
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
s.PushTask(task)
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) DownloadFiles() {
|
||||
go func() {
|
||||
var items []model.VideoJob
|
||||
logger.Info("[video] download files started")
|
||||
for {
|
||||
err := s.db.Where("status", types.VideoStatusDownloading).Find(&items).Error
|
||||
if err != nil {
|
||||
logger.Errorf("get downloading tasks with error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range items {
|
||||
if v.VideoURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Infof("try download video: %s", v.VideoURL)
|
||||
videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.VideoURL, ".mp4", true)
|
||||
if err != nil {
|
||||
logger.Errorf("download video with error: %v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("download video success: %s", videoURL)
|
||||
s.db.Model(&model.VideoJob{Id: v.Id}).UpdateColumns(map[string]any{
|
||||
"video_url": videoURL,
|
||||
"status": types.VideoStatusSuccess,
|
||||
"progress": 100,
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SyncTaskProgress 异步拉取任务
|
||||
func (s *Service) SyncTaskProgress() {
|
||||
go func() {
|
||||
logger.Info("[video] task status poller started")
|
||||
var jobs []model.VideoJob
|
||||
for {
|
||||
res := s.db.Where("status IN ?", []string{types.VideoStatusInProgress, types.VideoStatusPending}).Where("task_id <> ?", "").Find(&jobs)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
if len(jobs) > 0 {
|
||||
logger.Infof("[video] polling task status, in_progress count=%d", len(jobs))
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
// 检查任务是否超时(超过 2 小时)
|
||||
if time.Since(job.CreatedAt) > 2*time.Hour {
|
||||
logger.Warnf("[video] task timeout jobId=%d taskId=%s created_at=%s", job.Id, job.TaskId, job.CreatedAt.Format(time.RFC3339))
|
||||
err := s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]any{
|
||||
"status": types.VideoStatusFailed,
|
||||
"err_msg": "任务超时",
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("[video] update timeout task failed jobId=%d: %v", job.Id, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
modelKey := ""
|
||||
var videoTask types.VideoTask
|
||||
if err := json.Unmarshal([]byte(job.Params), &videoTask); err == nil {
|
||||
if paramsMap, ok := videoTask.Params.(map[string]any); ok {
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
modelKey = model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf("[video] querying task jobId=%d taskId=%s provider=%s", job.Id, job.TaskId, job.Type)
|
||||
task, err := s.QueryTask(job.Type, job.TaskId, job.Channel, modelKey)
|
||||
if err != nil {
|
||||
logger.Errorf("[video] query failed jobId=%d taskId=%s: %v", job.Id, job.TaskId, err)
|
||||
// 更新任务信息
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]any{
|
||||
"status": types.VideoStatusFailed,
|
||||
"err_msg": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debugf("[video] task status jobId=%d taskId=%s status=%s", job.Id, job.TaskId, task.Status)
|
||||
logger.Debugf("[video] output=%s", task.Output)
|
||||
|
||||
if task.Status == types.VideoStatusSuccess {
|
||||
data := map[string]any{
|
||||
"status": types.VideoStatusDownloading,
|
||||
"progress": 100,
|
||||
"output": task.Output,
|
||||
}
|
||||
if task.VideoURL != "" {
|
||||
data["video_url"] = task.VideoURL
|
||||
}
|
||||
err = s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(data).Error
|
||||
if err != nil {
|
||||
logger.Errorf("更新数据库失败:%v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("[video] task completed jobId=%d taskId=%s", job.Id, job.TaskId)
|
||||
} else if task.Status == "failed" {
|
||||
logger.Warnf("[video] task failed jobId=%d taskId=%s err=%s", job.Id, job.TaskId, task.ErrMsg)
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]any{
|
||||
"status": types.VideoStatusFailed,
|
||||
"err_msg": task.ErrMsg,
|
||||
})
|
||||
} else {
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]any{
|
||||
"status": task.Status,
|
||||
"progress": task.Progress,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
s.db.Select("id", "user_id", "power", "task_id", "err_msg", "type").
|
||||
Where("status", types.VideoStatusFailed).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: job.Type,
|
||||
Remark: fmt.Sprintf("%s 任务失败,退回算力。任务ID:%s,Err:%s", job.Type, job.TaskId, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// 更新任务状态
|
||||
s.db.Model(&job).UpdateColumn("power", 0)
|
||||
}
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WxGzhService 微信公众号服务
|
||||
type WxGzhService struct {
|
||||
config types.WxGzhConfig
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func (s *WxGzhService) UpdateConfig(config types.WxGzhConfig) {
|
||||
s.config = config
|
||||
}
|
||||
|
||||
func (s *WxGzhService) GetConfig() types.WxGzhConfig {
|
||||
return s.config
|
||||
}
|
||||
|
||||
func (s *WxGzhService) SetConfig(config types.WxGzhConfig) {
|
||||
s.config = config
|
||||
}
|
||||
|
||||
func NewWxGzhService(config *types.SystemConfig, db *gorm.DB) *WxGzhService {
|
||||
return &WxGzhService{config: config.WxGzh, DB: db}
|
||||
}
|
||||
|
||||
// GetOpenIDByCode 根据 code 获取 openid 和 access_token
|
||||
func (s *WxGzhService) GetOpenIDByCode(code string) (string, string, error) {
|
||||
|
||||
var config model.Config
|
||||
s.DB.Where("name", types.ConfigKeyWxGzh).First(&config)
|
||||
|
||||
var value map[string]any
|
||||
err := utils.JsonDecode(config.Value, &value)
|
||||
|
||||
url := fmt.Sprintf("https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code",
|
||||
value["app_id"], value["secret"], code)
|
||||
|
||||
body, status, err := utils.FetchURLBytes(context.Background(), url, "", 30*time.Second, 2, 2<<20)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("wx get openid failed: status=%d: %w", status, err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if openID, ok := result["openid"].(string); ok {
|
||||
if accessToken, ok := result["access_token"].(string); ok {
|
||||
return openID, accessToken, nil
|
||||
}
|
||||
}
|
||||
|
||||
if errMsg, ok := result["errmsg"].(string); ok {
|
||||
return "", "", fmt.Errorf("微信 API 错误: %s", errMsg)
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("获取 openid 和 access_token 失败: %s", string(body))
|
||||
}
|
||||
|
||||
// GetUserInfo 获取微信用户昵称和头像
|
||||
func (s *WxGzhService) GetUserInfo(accessToken string, openID string) (map[string]any, error) {
|
||||
url := fmt.Sprintf("https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s&lang=zh_CN",
|
||||
accessToken, openID)
|
||||
|
||||
body, status, err := utils.FetchURLBytes(context.Background(), url, "", 30*time.Second, 2, 2<<20)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wx get userinfo failed: status=%d: %w", status, err)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if errMsg, ok := result["errmsg"].(string); ok {
|
||||
return nil, fmt.Errorf("微信 API 错误: %s", errMsg)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
+12
-12
@@ -5,18 +5,18 @@ import (
|
||||
)
|
||||
|
||||
type ChatApp struct {
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"column:name;type:varchar(30);not null;comment:角色名称" json:"name"`
|
||||
Tid uint `gorm:"column:tid;type:int(11);not null;comment:分类ID" json:"tid"`
|
||||
Key string `gorm:"column:marker;type:varchar(30);uniqueIndex;not null;comment:角色标识" json:"marker"`
|
||||
Context string `gorm:"column:context_json;type:text;not null;comment:角色语料 json" json:"context_json"`
|
||||
HelloMsg string `gorm:"column:hello_msg;type:varchar(255);not null;comment:打招呼信息" json:"hello_msg"`
|
||||
Icon string `gorm:"column:icon;type:varchar(255);not null;comment:角色图标" json:"icon"`
|
||||
Enable bool `gorm:"column:enable;type:tinyint(1);not null;comment:是否被启用" json:"enable"`
|
||||
SortNum int `gorm:"column:sort_num;type:smallint;not null;default:0;comment:角色排序" json:"sort_num"`
|
||||
ModelId uint `gorm:"column:model_id;type:int(11);not null;default:0;comment:绑定模型ID" json:"model_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"column:name;type:varchar(30);not null;comment:角色名称" json:"name"`
|
||||
Tid uint `gorm:"column:tid;type:int(11);not null;comment:分类ID" json:"tid"`
|
||||
UserId uint `gorm:"column:user_id;type:int(11);not null;default:0;comment:所属用户ID,为 0 表示系统内置" json:"user_id"`
|
||||
SystemPrompt string `gorm:"column:system_prompt;type:text;not null;comment:系统提示词" json:"system_prompt"`
|
||||
HelloMsg string `gorm:"column:hello_msg;type:varchar(255);not null;comment:打招呼信息" json:"hello_msg"`
|
||||
Icon string `gorm:"column:icon;type:varchar(255);not null;comment:角色图标" json:"icon"`
|
||||
Enable bool `gorm:"column:enable;type:tinyint(1);not null;comment:是否被启用" json:"enable"`
|
||||
SortNum int `gorm:"column:sort_num;type:smallint;not null;default:0;comment:角色排序" json:"sort_num"`
|
||||
ModelId uint `gorm:"column:model_id;type:int(11);not null;default:0;comment:绑定模型ID" json:"model_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (m *ChatApp) TableName() string {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type DallJob struct {
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserId uint `gorm:"column:user_id;type:int(11);not null;comment:用户ID" json:"user_id"`
|
||||
Prompt string `gorm:"column:prompt;type:text;not null;comment:提示词" json:"prompt"`
|
||||
TaskInfo string `gorm:"column:task_info;type:text;not null;comment:任务详情" json:"task_info"`
|
||||
ImgURL string `gorm:"column:img_url;type:varchar(255);not null;comment:图片地址" json:"img_url"`
|
||||
OrgURL string `gorm:"column:org_url;type:varchar(1024);comment:原图地址" json:"org_url"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
Power int `gorm:"column:power;type:smallint;not null;comment:消耗算力" json:"power"`
|
||||
Progress int `gorm:"column:progress;type:smallint;not null;comment:任务进度" json:"progress"`
|
||||
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);not null;comment:错误信息" json:"err_msg"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
}
|
||||
|
||||
func (m *DallJob) TableName() string {
|
||||
return "geekai_dall_jobs"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ImageJob struct {
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserId uint `gorm:"column:user_id;type:int(11);not null;comment:用户ID" json:"user_id"`
|
||||
Prompt string `gorm:"column:prompt;type:text;not null;comment:提示词" json:"prompt"`
|
||||
Params string `gorm:"column:params;type:text;not null;comment:任务参数" json:"params"`
|
||||
TaskId string `gorm:"column:task_id;type:varchar(64);comment:Kapon 异步任务 ID" json:"task_id"`
|
||||
ImgURL string `gorm:"column:img_url;type:varchar(255);not null;comment:图片地址" json:"img_url"`
|
||||
OrgURL string `gorm:"column:org_url;type:varchar(1024);comment:原图地址" json:"org_url"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
Power int `gorm:"column:power;type:smallint;not null;comment:消耗算力" json:"power"`
|
||||
Progress int `gorm:"column:progress;type:smallint;not null;comment:任务进度" json:"progress"`
|
||||
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);not null;comment:错误信息" json:"err_msg"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
}
|
||||
|
||||
func (m *ImageJob) TableName() string {
|
||||
return "geekai_image_jobs"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"geekai/store/vo"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PPTJob PPT 生成任务(持久化到数据库)
|
||||
type PPTJob struct {
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
TaskId string `gorm:"column:task_id;type:varchar(64);uniqueIndex;not null;comment:任务 ID" json:"task_id"`
|
||||
UserId uint `gorm:"column:user_id;type:int(11);not null;index;comment:用户 ID" json:"user_id"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:pending;comment:任务状态 pending,processing,completed,failed" json:"status"`
|
||||
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);comment:错误信息" json:"err_msg"`
|
||||
Prompt string `gorm:"column:prompt;type:text;comment:用户补充提示" json:"prompt"`
|
||||
Title string `gorm:"column:title;type:varchar(256);default:'';comment:列表展示标题(LLM)" json:"title"`
|
||||
Thumb string `gorm:"column:thumb;type:varchar(1024);default:'';comment:列表缩略图 URL" json:"thumb"`
|
||||
Content string `gorm:"column:content;type:text;not null;comment:PPT 内容大纲" json:"content"`
|
||||
Params vo.PPTParams `gorm:"column:params;type:text;comment:生成参数 JSON(语言,生成模式,页数)" json:"params"`
|
||||
Slides vo.PPTSlides `gorm:"column:slides;type:text;comment:生成的幻灯片列表 JSON" json:"slides"`
|
||||
TotalSlides int `gorm:"column:total_slides;type:int(11);default:0;comment:总页数" json:"total_slides"`
|
||||
CompletedSlides int `gorm:"column:completed_slides;type:int(11);default:0;comment:已完成页数" json:"completed_slides"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (m *PPTJob) TableName() string {
|
||||
return "geekai_ppt_jobs"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type SdJob struct {
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserId uint `gorm:"column:user_id;type:int(11);not null;comment:用户 ID" json:"user_id"`
|
||||
Type string `gorm:"column:type;type:varchar(20);default:txt2img;comment:任务类别" json:"type"`
|
||||
TaskId string `gorm:"column:task_id;type:char(30);uniqueIndex;not null;comment:任务 ID" json:"task_id"`
|
||||
TaskInfo string `gorm:"column:task_info;type:text;not null;comment:任务详情" json:"task_info"`
|
||||
Prompt string `gorm:"column:prompt;type:text;not null;comment:会话提示词" json:"prompt"`
|
||||
ImgURL string `gorm:"column:img_url;type:varchar(255);comment:图片URL" json:"img_url"`
|
||||
Params string `gorm:"column:params;type:text;comment:绘画参数json" json:"params"`
|
||||
Progress int `gorm:"column:progress;type:smallint;default:0;comment:任务进度" json:"progress"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);comment:错误信息" json:"err_msg"`
|
||||
Power int `gorm:"column:power;type:smallint;not null;default:0;comment:消耗算力" json:"power"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
}
|
||||
|
||||
func (m *SdJob) TableName() string {
|
||||
return "geekai_sd_jobs"
|
||||
}
|
||||
+25
-26
@@ -1,33 +1,32 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"geekai/store/vo"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SunoJob struct {
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserId uint `gorm:"column:user_id;type:int;not null;comment:用户 ID" json:"user_id"`
|
||||
Channel string `gorm:"column:channel;type:varchar(100);not null;comment:渠道" json:"channel"`
|
||||
Title string `gorm:"column:title;type:varchar(100);comment:歌曲标题" json:"title"`
|
||||
Type int `gorm:"column:type;type:tinyint(1);default:0;comment:任务类型,1:灵感创作,2:自定义创作" json:"type"`
|
||||
TaskId string `gorm:"column:task_id;type:varchar(50);comment:任务 ID" json:"task_id"`
|
||||
TaskInfo string `gorm:"column:task_info;type:text;not null;comment:任务详情" json:"task_info"`
|
||||
RefTaskId string `gorm:"column:ref_task_id;type:char(50);comment:引用任务 ID" json:"ref_task_id"`
|
||||
Tags string `gorm:"column:tags;type:varchar(255);comment:歌曲风格" json:"tags"`
|
||||
Instrumental bool `gorm:"column:instrumental;type:tinyint(1);default:0;comment:是否为纯音乐" json:"instrumental"`
|
||||
ExtendSecs int `gorm:"column:extend_secs;type:smallint;default:0;comment:延长秒数" json:"extend_secs"`
|
||||
SongId string `gorm:"column:song_id;type:varchar(50);comment:要续写的歌曲 ID" json:"song_id"`
|
||||
RefSongId string `gorm:"column:ref_song_id;type:varchar(50);not null;comment:引用的歌曲ID" json:"ref_song_id"`
|
||||
Prompt string `gorm:"column:prompt;type:varchar(2000);not null;comment:提示词" json:"prompt"`
|
||||
CoverURL string `gorm:"column:cover_url;type:varchar(512);comment:封面图地址" json:"cover_url"`
|
||||
AudioURL string `gorm:"column:audio_url;type:varchar(512);comment:音频地址" json:"audio_url"`
|
||||
ModelName string `gorm:"column:model_name;type:varchar(30);comment:模型地址" json:"model_name"`
|
||||
Progress int `gorm:"column:progress;type:smallint;default:0;comment:任务进度" json:"progress"`
|
||||
Duration int `gorm:"column:duration;type:smallint;not null;default:0;comment:歌曲时长" json:"duration"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);comment:错误信息" json:"err_msg"`
|
||||
RawData string `gorm:"column:raw_data;type:text;comment:原始数据" json:"raw_data"`
|
||||
Power int `gorm:"column:power;type:smallint;not null;default:0;comment:消耗算力" json:"power"`
|
||||
PlayTimes int `gorm:"column:play_times;type:int;comment:播放次数" json:"play_times"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserId uint `gorm:"column:user_id;type:int;not null;comment:用户 ID" json:"user_id"`
|
||||
Channel string `gorm:"column:channel;type:varchar(100);not null;comment:渠道" json:"channel"`
|
||||
Title string `gorm:"column:title;type:varchar(100);comment:歌曲标题" json:"title"`
|
||||
Type int `gorm:"column:type;type:tinyint(1);default:0;comment:任务类型,1:灵感创作,2:自定义创作" json:"type"`
|
||||
TaskId string `gorm:"column:task_id;type:varchar(50);comment:任务 ID" json:"task_id"`
|
||||
Params vo.SunoParam `gorm:"column:params;type:text;comment:任务参数" json:"params"`
|
||||
RefTaskId string `gorm:"column:ref_task_id;type:char(50);comment:引用任务 ID" json:"ref_task_id"`
|
||||
SongId string `gorm:"column:song_id;type:varchar(50);comment:要续写的歌曲 ID" json:"song_id"`
|
||||
RefSongId string `gorm:"column:ref_song_id;type:varchar(50);not null;comment:引用的歌曲ID" json:"ref_song_id"`
|
||||
Prompt string `gorm:"column:prompt;type:varchar(2000);not null;comment:提示词" json:"prompt"`
|
||||
CoverURL string `gorm:"column:cover_url;type:varchar(512);comment:封面图地址" json:"cover_url"`
|
||||
AudioURL string `gorm:"column:audio_url;type:varchar(512);comment:音频地址" json:"audio_url"`
|
||||
Progress int `gorm:"column:progress;type:smallint;default:0;comment:任务进度" json:"progress"`
|
||||
Duration int `gorm:"column:duration;type:smallint;not null;default:0;comment:歌曲时长" json:"duration"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);comment:错误信息" json:"err_msg"`
|
||||
Output string `gorm:"column:output;type:text;comment:原始输出数据" json:"output"`
|
||||
Power int `gorm:"column:power;type:smallint;not null;default:0;comment:消耗算力" json:"power"`
|
||||
PlayTimes int `gorm:"column:play_times;type:int;comment:播放次数" json:"play_times"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
}
|
||||
|
||||
func (m *SunoJob) TableName() string {
|
||||
|
||||
@@ -17,13 +17,14 @@ type User struct {
|
||||
ExpiredTime int64 `gorm:"column:expired_time;type:int;not null;comment:用户过期时间" json:"expired_time"`
|
||||
Status bool `gorm:"column:status;type:tinyint(1);not null;comment:当前状态" json:"status"`
|
||||
ChatConfig string `gorm:"column:chat_config_json;type:text;default:null;comment:聊天配置json" json:"chat_config"`
|
||||
ChatRoles string `gorm:"column:chat_roles_json;type:text;default:null;comment:聊天角色 json" json:"chat_roles"`
|
||||
ChatRoles string `gorm:"column:chat_roles_json;type:text;default:null;comment:聊天角色 json" json:"-"`
|
||||
ChatModels string `gorm:"column:chat_models_json;type:text;default:null;comment:AI模型 json" json:"chat_models"`
|
||||
LastLoginAt int64 `gorm:"column:last_login_at;type:int;not null;comment:最后登录时间" json:"last_login_at"`
|
||||
Vip bool `gorm:"column:vip;type:tinyint(1);not null;default:0;comment:是否会员" json:"vip"`
|
||||
LastLoginIp string `gorm:"column:last_login_ip;type:char(32);not null;comment:最后登录 IP" json:"last_login_ip"`
|
||||
OpenId string `gorm:"column:openid;type:varchar(100);comment:第三方登录账号ID" json:"openid"`
|
||||
Platform string `gorm:"column:platform;type:varchar(30);comment:登录平台" json:"platform"`
|
||||
GemIds string `gorm:"column:gem_ids_json;type:text;default:null;comment:用户固定的智能体ID列表(JSON数组)" json:"gem_ids"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -7,17 +7,15 @@ type VideoJob struct {
|
||||
UserId uint `gorm:"column:user_id;type:int(11);not null;comment:用户 ID" json:"user_id"`
|
||||
Channel string `gorm:"column:channel;type:varchar(100);not null;comment:渠道" json:"channel"`
|
||||
TaskId string `gorm:"column:task_id;type:varchar(100);not null;comment:任务 ID" json:"task_id"`
|
||||
TaskInfo string `gorm:"column:task_info;type:text;comment:原始任务信息" json:"task_info"`
|
||||
Params string `gorm:"column:params;type:text;comment:视频任务参数 JSON" json:"params"`
|
||||
Type string `gorm:"column:type;type:varchar(20);comment:任务类型,luma,runway,cogvideo" json:"type"`
|
||||
Prompt string `gorm:"column:prompt;type:text;not null;comment:提示词" json:"prompt"`
|
||||
PromptExt string `gorm:"column:prompt_ext;type:text;comment:优化后提示词" json:"prompt_ext"`
|
||||
CoverURL string `gorm:"column:cover_url;type:varchar(512);comment:封面图地址" json:"cover_url"`
|
||||
VideoURL string `gorm:"column:video_url;type:varchar(512);comment:视频地址" json:"video_url"`
|
||||
WaterURL string `gorm:"column:water_url;type:varchar(512);comment:带水印的视频地址" json:"water_url"`
|
||||
Progress int `gorm:"column:progress;type:smallint;default:0;comment:任务进度" json:"progress"`
|
||||
VideoURL string `gorm:"column:video_url;type:varchar(2000);not null;comment:视频地址" json:"video_url"`
|
||||
Status string `gorm:"column:status;type:varchar(20);default:pending;comment:任务状态:pending,in_progress,downloading,success,failed" json:"status"`
|
||||
Progress int `gorm:"column:progress;type:smallint;default:0;comment:任务进度(0-100)" json:"progress"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);comment:错误信息" json:"err_msg"`
|
||||
RawData string `gorm:"column:raw_data;type:text;comment:原始数据" json:"raw_data"`
|
||||
Output string `gorm:"column:output;type:text;comment:任务输出的原始信息" json:"output"`
|
||||
Power int `gorm:"column:power;type:smallint;not null;default:0;comment:消耗算力" json:"power"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
}
|
||||
|
||||
+8
-8
@@ -10,20 +10,20 @@ package store
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
logger2 "gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
var log = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
func NewGormConfig() *gorm.Config {
|
||||
return &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
Logger: logger2.Default.LogMode(logger2.Warn),
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "geekai_", // 设置表前缀
|
||||
SingularTable: false, // 使用单数表名形式
|
||||
@@ -46,7 +46,7 @@ func NewMysql(config *gorm.Config, appConfig *types.AppConfig) (*gorm.DB, error)
|
||||
sqlDB.SetMaxOpenConns(512)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
log.Info("开始重命名数据表...")
|
||||
logger.Info("开始重命名数据表...")
|
||||
|
||||
// 重命名数据表
|
||||
tableRenames := map[string]string{
|
||||
@@ -61,12 +61,12 @@ func NewMysql(config *gorm.Config, appConfig *types.AppConfig) (*gorm.DB, error)
|
||||
"chatgpt_orders": "geekai_orders",
|
||||
"chatgpt_products": "geekai_products",
|
||||
"chatgpt_configs": "geekai_configs",
|
||||
"chatgpt_sd_jobs": "geekai_sd_jobs",
|
||||
"chatgpt_mj_jobs": "geekai_mj_jobs",
|
||||
"chatgpt_suno_jobs": "geekai_suno_jobs",
|
||||
"chatgpt_dall_jobs": "geekai_dall_jobs",
|
||||
"chatgpt_video_jobs": "geekai_video_jobs",
|
||||
"chatgpt_jimeng_jobs": "geekai_jimeng_jobs",
|
||||
"chatgpt_ppt_jobs": "geekai_ppt_jobs",
|
||||
"chatgpt_files": "geekai_files",
|
||||
"chatgpt_menus": "geekai_menus",
|
||||
"chatgpt_functions": "geekai_functions",
|
||||
@@ -83,9 +83,9 @@ func NewMysql(config *gorm.Config, appConfig *types.AppConfig) (*gorm.DB, error)
|
||||
if !db.Migrator().HasTable(newTableName) {
|
||||
err := db.Exec(fmt.Sprintf("ALTER TABLE %s RENAME TO %s", oldTableName, newTableName)).Error
|
||||
if err != nil {
|
||||
log.Errorf("重命名数据表 %s 到 %s 失败: %v", oldTableName, newTableName, err)
|
||||
logger.Errorf("重命名数据表 %s 到 %s 失败: %v", oldTableName, newTableName, err)
|
||||
} else {
|
||||
log.Infof("成功重命名数据表: %s -> %s", oldTableName, newTableName)
|
||||
logger.Infof("成功重命名数据表: %s -> %s", oldTableName, newTableName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-13
@@ -1,18 +1,16 @@
|
||||
package vo
|
||||
|
||||
import "geekai/core/types"
|
||||
|
||||
type ChatApp struct {
|
||||
BaseVo
|
||||
Key string `json:"key"` // 角色唯一标识
|
||||
Tid uint `json:"tid"`
|
||||
Name string `json:"name"` // 角色名称
|
||||
Context []types.Message `json:"context"` // 角色语料信息
|
||||
HelloMsg string `json:"hello_msg"` // 打招呼的消息
|
||||
Icon string `json:"icon"` // 角色聊天图标
|
||||
Enable bool `json:"enable"` // 是否启用被启用
|
||||
SortNum int `json:"sort"` // 排序
|
||||
ModelId uint `json:"model_id"` // 绑定模型 ID
|
||||
ModelName string `json:"model_name"` // 模型名称
|
||||
TypeName string `json:"type_name"` // 分类名称
|
||||
Tid uint `json:"tid"`
|
||||
Name string `json:"name"` // 角色名称
|
||||
UserId uint `json:"user_id"` // 所属用户 ID,0 表示系统内置
|
||||
SystemPrompt string `json:"system_prompt,omitempty"` // 系统提示词(列表接口对内置智能体不下发)
|
||||
HelloMsg string `json:"hello_msg"` // 打招呼的消息
|
||||
Icon string `json:"icon"` // 角色聊天图标
|
||||
Enable bool `json:"enable"` // 是否启用
|
||||
SortNum int `json:"sort"` // 排序
|
||||
ModelId uint `json:"model_id"` // 绑定模型 ID
|
||||
ModelName string `json:"model_name"` // 模型名称
|
||||
TypeName string `json:"type_name"` // 分类名称
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package vo
|
||||
|
||||
type DallJob struct {
|
||||
Id uint `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
ImgURL string `json:"img_url"`
|
||||
OrgURL string `json:"org_url"`
|
||||
Publish bool `json:"publish"`
|
||||
Power int `json:"power"`
|
||||
Progress int `json:"progress"`
|
||||
ErrMsg string `json:"err_msg"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package vo
|
||||
|
||||
type ImageJob struct {
|
||||
Id uint `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Params string `json:"params"`
|
||||
ImgURL string `json:"img_url"`
|
||||
OrgURL string `json:"org_url"`
|
||||
Publish bool `json:"publish"`
|
||||
Power int `json:"power"`
|
||||
Progress int `json:"progress"`
|
||||
ErrMsg string `json:"err_msg"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user