feat: Dify 部署脚本修复 + AI 搭子 MVP 接入

主要改动:

fix(docker/dify-deploy): 修复脚本核心功能
- heredoc 单引号 bug: 'ENVEOF' 改为 ENVEOF,变量正确展开
- 端口默认值 8083/8084/8085 对齐 .env.prod 生产配置
- 加 dc_cmd() 兼容 docker-compose v1/v2 plugin
- openssl rand 生成强随机密码与 SECRET_KEY(42 字符)
- install 跳过已存在 .env,保护用户配置(管理员密码/SECRET_KEY)
- read -p < /dev/tty 兼容非 tty 环境(CI/CD)
- show-config 改用 DIFY_NGINX_PORT(nginx 入口)而非 APP_WEB_PORT

docs(mvp-design): 修正 §3.2 workflow inputs 描述
- 实际只有 query,删除错误的 user_id input 声明
- 节点序列图同步更新

feat(aiChatService): 新增 Dify 客户端与适配器
- service/dify_client.go: Dify Workflow 调用 + SSE 解析
- service/dify_adapter.go: 与现有 chat_service 桥接
- provider/ai_chat_provider.go: Dubbo 入口简化
- main.go: 装配 ConversationRepository + DifyClient

feat(migrations): 新增 AI 搭子会话表 ai_chat.sql
- ai_conversations / ai_messages 表 + 索引

docs: 新增 Dify 集成设计文档
- 2026-06-29-ai-chat-dify-mvp-design.md (MVP 实施级)
- 2026-06-29-ai-chat-dify-integration-v2-design.md (V2 演进路线图)
- docs/dify/角角.yml (Workflow DSL 导出)

config: 更新 env 模板与 docker 配置
- backend/.env.example: DIFY_* 环境变量声明
- docker/.env.prod: DIFY_API_BASE 对齐 8083
- docker/build.sh: 微调
- CLAUDE.md: 项目规范补充

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Lenticular Studio Agent 2026-07-02 12:24:04 +08:00
parent a077fe2685
commit 65ce6bba12
17 changed files with 6620 additions and 42 deletions

217
CLAUDE.md
View File

@ -98,6 +98,63 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
---
## 前端开发规范uniapp + vue3 · app 端)
### 技术栈基线
- **框架**UniApp 3.x + **Vue 3 组合式 API**`vueVersion: "3"``@vue/compiler-sfc ^3.5`),不要再写 Vue 2 Options API 或混用 `this`
- **状态管理**Vuex 4`store/index.js` + `store/modules/*`),跨页面状态走 Vuex组件临时状态用 `ref` / `reactive`
- **复用逻辑**:放进 `composables/useXxx.js`(已有 `useHolographicPreview` / `useDashboardData` / `useLenticularStudioTilt` 等),**禁止**把可复用的逻辑写在单文件组件里
- **目标平台**:以 **app-plusAndroid + iOS为主**H5 / 微信小程序等其他端仅在显式 `#ifdef` 支持时才能用
- **原生能力**`plus.*`陀螺仪、角标、Intent 跳转等、UniPush、设备指纹、Socket 全都走 `utils/` 下专用封装,**不要**在组件里直接 `plus.*`
### 关键约定
1. **条件编译是硬约束**——涉及原生 API / 原生插件 / 平台差异代码必须包在 `// #ifdef APP-PLUS … // #endif`(或 `MP-WEIXIN` / `H5` 等):
```js
// 正确
// #ifdef APP-PLUS
plus.runtime.setBadgeNumber(0)
// #endif
// 错误plus 在非 app 端是 undefined会直接报错
plus.runtime.setBadgeNumber(0)
```
**禁止**用 `if (typeof plus !== 'undefined')` 之类兜底代替条件编译。
2. **API 调用统一封装**——所有后端接口走 `utils/api.js`设备注册、WebSocket token 上报等),组件层只调封装函数,**禁止**在 `.vue` 里直接 `uni.request`
3. **路由与页面注册**——新页面**先在 [frontend/pages.json](frontend/pages.json) 注册再写 `.vue` 文件**Tab 页面用 `uni.switchTab`,普通跳转用 `navigateTo`**禁止**用 `redirectTo` 替代 `navigateBack` 制造假"返回"。
4. **权限申请必须给出口**——通知 / 相机 / 相册 / 定位等敏感权限,授权失败时必须给"去设置"按钮并调用系统设置页(参照 `App.vue#setPermissions` 的 Android Intent / iOS `app-settings:` 范式),**禁止**静默失败。
5. **性能基线**——长列表使用现有 [components/VirtualList.vue](frontend/components/VirtualList.vue),图片用 [components/LazyImage.vue](frontend/components/LazyImage.vue);自定义字体已知坑:部分 Android WebView 对部分 `.ttf` 会报 OTS / cmap 解析失败(如 `JDLTYuanTiJian.ttf`),新增字体前先在小内存 Android 机上验证。
6. **资源与产物隔离**——`unpackage/dist/*` 是编译产物,**禁止**手动修改;图标准备物放在 `unpackage/res/icons/`,源码改动放 `static/`
### 禁止的反模式
- ❌ 在 Vue 3 项目里混用 `export default { data() { return {} } }` Options API
- ❌ 在非 `APP-PLUS` 分支直接调 `plus.*` / 原生插件 API
- ❌ 在组件里直接 `uni.request` / `uni.connectSocket`,绕过 `utils/api.js`
- ❌ 跨页面状态用 props 层层下传 / `getApp().globalData` 散落——必须走 Vuex
- ❌ 新增页面不写 `pages.json` 就提交
- ❌ 权限被拒后只 `console.warn` 不引导用户去开启
- ❌ 手动编辑 `unpackage/dist/` 下的任何文件
### 完成自检(提交前过一遍)
- [ ] 新组件用 `<script setup>``setup()` 组合式 API`this`
- [ ] 所有原生 / 平台差异代码包了 `#ifdef`
- [ ] 接口调用走 `utils/api.js`,未在组件里裸写 `uni.request`
- [ ] 跨页面 / 跨组件状态走 Vuex
- [ ] 新页面已在 `pages.json` 注册
- [ ] 敏感权限失败有"去设置"出口
- [ ] 长列表 / 大图场景接入了虚拟滚动或懒加载
- [ ] 未改动 `unpackage/dist/` 编译产物
---
## Git 提交规范
### 核心规则AI 不得主动 commit
@ -164,6 +221,166 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
- 用户对代码质量失去信任
- 提交历史变成"反复横跳"的打补丁记录
### ★ 全局自审规则(强约束)
**自审必须是"全局审查",不能只读"我修改的部分"**。
#### 错误做法(局部自审,已多次踩坑)
- 只读修改过的章节,找修改自身的 bug
- 跳过未修改的章节,认为"我没动过所以没问题"
- 只检查修复后的代码,不检查修复是否破坏了其他章节的引用
#### 正确做法(全局自审,强制执行)
每次自审必须**从文档第一节读到最后一节**,按以下步骤:
1. **章节通读清单**先列出文档所有章节§1 到 §16逐一阅读
2. **跨章节引用一致性检查**
- §1.x 描述的事实 → 在 §5.x 实现了吗?
- §3 Dify 配置 → §5 后端是否一致?
- §10 目录 → §5 代码是否齐全?
- §11 部署 → §5 装配是否对齐?
3. **代码示例完整性**
- Go import 块是否齐全time/strings/redis 等常用包)
- 函数签名与调用点参数个数一致
- struct 字段与构造方法一致
4. **章节编号一致性**§3.4 与 §3.5 不重复§11.x 编号无错位
5. **架构图 vs 时序图 vs 代码**:物理时序、逻辑步骤、代码实现三者交叉验证
#### 失败案例2026-06-29 V2 文档自审 6 轮)
- **第 1-4 轮自审漏发现的问题**DifyProvider 用 `p.httpClient`/`p.redisClient`/`p.convRepo`,新设计应该用组件抽象,但旧代码没改干净
- **Workflow 双 mapping**V1 设计 bugBackend 维护 `star_dataset_mapping` + Workflow 维护 `STAR_DATASET_MAP`6 轮自审都没看到,是架构评审指出的
- **§11 部署章节**持续出现角色分工错位、循环引用
**根因**:我之前的"自审"本质上是"局部自审",只看我改的部分。
#### 自审触发时机
- 写完设计方案文档后
- 完成一轮"修复 X 个 bug"后
- 实施编码 `go build` 前(最后一次文档校对)
#### 自审报告必须包含
1. **修改的章节**:列出本次修了哪些章节
2. **未改动的章节**:列出本次没动但通读了哪些章节(防止"我没看"的盲区)
3. **跨章节引用一致性**:列出所有发现的不一致
4. **Go 编译验证**:列出所有需要 `go build` 才能发现的潜在问题
5. **优先级**P0/P1/P2 分类
---
## 文档维护规则(设计方案类)
### 设计方案文档必须包含的开头部分
任何设计方案文档(如 `docs/specs/*.md`**必须在文档开头**包含:
1. **方案概述***必读*
- **要解决的问题**:业务问题 + 技术问题(分类列出)
- **整体实现路径**:阶段划分 + 时间估算
- **关键决策**:核心设计选择的简要说明 + 指向详细章节
- **核心架构图**TL;DR一图说明整体结构
2. **文档说明**
- 适用范围
- 工作量估算
- 前置版本/历史
- 目标读者
**目的**:让读者**5 分钟内**能判断这个文档"是不是我需要的" + "大概什么内容"。
### ★ MVP 先行原则(业务驱动,不是架构驱动)
**核心原则MVP 阶段不实施"为未来 100 明星 + 多 AI 平台"准备的架构**。
**错误做法(已踩坑)**
- 业务第一阶段只有 1 个明星,但设计文档规划了 Provider 抽象、ProviderFactory、ConversationStore 抽象
- 结果MVP 阶段写了 1500-2000 行抽象代码,**80% 用不上**
- 后果:实施周期 4-5 周(应该 1 周),新人接手困难,运营被复杂架构拖慢
**正确做法**
- **MVP 设计原则**MVP 文档只描述当前业务需要的实现
- **Stage 1**: 1 个明星 + 1 个 Dify Workflow + 直接调 DifyClient**没有 Provider 抽象**
- **Stage 2-5**: 业务复杂度上来后,**按业务驱动**逐步加抽象
**设计文档结构(**双文档体系****
| 文档 | 用途 | 实施阶段 |
|------|------|---------|
| **MVP 设计**(如 `*-mvp-design.md` | MVP 实施级方案1 周可落地 | MVP 阶段 |
| **完整架构**(如 `*-v2-design.md` | 100 明星 + 多 AI 平台完整设计 | Stage 2+ 参考 |
**两个文档的关系**
- MVP 文档开头要明确"★ MVP 优先"提示
- 完整架构文档开头要明确"⚠️ MVP 不实施,仅路线图"
- 完整架构文档的 §10"后续优化"映射到 MVP 的 Stage 1-5 演进路径
**判断要不要做架构的设计问题**
| 问题 | 答案 |
|------|------|
| "MVP 只有 1 个 Provider需要 AIProvider interface 吗?" | **不需要**YAGNI |
| "MVP 只有 1 个 Dataset需要 star_dataset_mapping 吗?" | **不需要**(写死) |
| "MVP 流量小,需要 RedisLock 防并发吗?" | **不需要**(单实例部署) |
| "MVP 不会换 Dify需要 MiniMax fallback 吗?" | **不需要**(错了就报错) |
| "MVP 想要对话跨天续接,需要 PostgreSQL 持久化吗?" | **需要**(追星场景长生命周期) |
| "MVP 想要拒答敏感词,需要 AuditService 吗?" | **需要**V1 已有) |
**反面案例**2026-06-29
- V2 文档设计了 Provider 抽象、ProviderFactory、ConversationStore 抽象、DatasetResolver、WorkflowClient/HistoryClient 拆分、AIProfile 等
- 实际 MVP 只需要JWT + ConversationRepository + DifyClient + StreamChat + 后置审核 + 保存消息
- 5 倍的复杂度,**0 业务价值**
### 设计方案文档的自审清单
每次完成/大改设计方案文档,必须做以下自审(除了上面"全局自审"通用规则外):
- [ ] 文档开头是否有"方案概述"
- [ ] "方案概述"是否包含:要解决的问题、实现路径、关键决策、核心架构图?
- [ ] **是否违反 MVP 先行原则?**(设计的抽象/复杂度是否超过当前业务需要)
- [ ] 实现路径是否给了明确的时间估算和里程碑?
- [ ] 关键决策是否能通过超链接定位到详细章节?
- [ ] 核心架构图是否覆盖了所有关键组件?
- [ ] 完整架构文档开头是否标注"MVP 不实施,仅路线图"
### 设计方案修改的同步原则
修改设计文档时,**必须同时检查**
1. **未改章节的引用一致性**(用 grep 查找旧字段名)
2. **目录列表与实际文件**§10 改了§11 也要同步)
3. **代码示例的语法**Go/Rust/Python 等)
4. **章节编号**(插入新章节后,后续编号是否需要顺延)
### 文档维护的"传染性"提醒
**修改一个章节会"传染"其他章节**
| 修改 A 章节 | 必须同步检查的章节 |
|------------|---------------------|
| §3 Dify 配置 | §5 后端 + §11 部署 + §10 目录 |
| §5 代码 | §6 时序图 + §11 main.go 装配 + §10 目录 |
| §10 目录 | §11 部署任务清单 + §5 文件名引用 |
| §11 部署 | §5 装配代码 + §10 文件列表 |
| §1 关键决策 | §14 对比表 + §3.3 实施 |
| **新增强大架构** | **是否违反 MVP 先行原则?是否需要拆为 MVP + 演进双文档?** |
---
## 本地规则说明
**这些规则是 Claude 在本仓库工作时必须遵守的本地约定**
- 不提交到 git除非用户明确指示
- 用户每次会话可能重复触发这些规则
- 修改 `CLAUDE.md` 内容需用户明确同意
- 规则优先级:用户消息 > CLAUDE.md > memory/ > 默认行为
---
---
## 接口开发规范

View File

@ -83,7 +83,7 @@ SEGMENT_INFERENCE_URL=
# Dify API 地址(自部署或云服务)
DIFY_API_BASE=http://localhost/v1
# Dify App API Keylaser_card_variants_v1 工作流)
DIFY_API_KEY=app-tIfFhFwj3xnbRurK1oxxBXnA
# DIFY_API_KEY=app-tIfFhFwj3xnbRurK1oxxBXnA
# Dify 工作流名称relay-dify 模式时使用的 Dify app用于 prompt 增强)
DIFY_WORKFLOW=laser_prompt_enhancer_v2
# ==================== 镭射卡生成器 ====================
@ -119,3 +119,8 @@ OPENAI_API_KEY=sk-eIOujD5rUugIRIPecFi3I2rFr6Bhxx1jsRzRm6phyNeeKrCI
OPENAI_BASE_URL=https://api.weda.cc/v1
# 中转站实际暴露的 image 模型
OPENAI_MODEL=gpt-image-2
# ==================== aichatdify ====================
DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ
DIFY_API_BASE=http://localhost/v1
DIFY_TIMEOUT_SEC=60

View File

@ -41,8 +41,27 @@ CREATE INDEX IF NOT EXISTS idx_ai_user_memories_user_id ON ai_user_memories(user
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_keywords ON ai_user_memories USING GIN(keywords);
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_weight ON ai_user_memories(weight DESC);
-- =============================================
-- 3. ai_chat_configs (配置表,存储 Dify/LLM 等配置)
-- =============================================
CREATE TABLE IF NOT EXISTS ai_chat_configs (
id SERIAL PRIMARY KEY,
config_key VARCHAR(128) UNIQUE NOT NULL,
config_value TEXT NOT NULL,
config_type VARCHAR(32) DEFAULT 'string',
category VARCHAR(64),
description VARCHAR(256),
is_encrypted BOOLEAN DEFAULT FALSE,
updated_at BIGINT,
created_at BIGINT
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_ai_chat_configs_category ON ai_chat_configs(category);
-- =============================================
-- 回滚语句 (如需回滚)
-- =============================================
-- DROP TABLE IF EXISTS ai_chat_configs;
-- DROP TABLE IF EXISTS ai_user_memories;
-- DROP TABLE IF EXISTS ai_personas;

View File

@ -172,20 +172,49 @@ func main() {
zap.Bool("has_qwen_key", qwenAPIKey != ""),
)
// 创建 Service 层实例
llmService := service.NewLLMService(
miniMaxAPIURL,
miniMaxAPIKey,
miniMaxModel,
qwenAPIURL,
qwenAPIKey,
qwenModel,
// 获取 Dify 配置MVP 阶段从环境变量读)
difyAPIKey := getEnv("DIFY_API_KEY", "")
difyAPIBase := getEnv("DIFY_API_BASE", "")
if val, ok := llmConfigs["dify.api_key"]; ok && val != "" {
difyAPIKey = val
}
if val, ok := llmConfigs["dify.api_base"]; ok && val != "" {
difyAPIBase = val
}
logger.Logger.Info("Dify config loaded",
zap.String("dify_api_base", difyAPIBase),
zap.Bool("has_dify_key", difyAPIKey != ""),
)
// 创建 AI ProviderDify 或 LLM
var aiProvider service.AIProvider
if difyAPIKey != "" {
difyClient := service.NewDifyClient(service.DifyConfig{
APIBase: difyAPIBase,
APIKey: difyAPIKey,
TimeoutSec: 120,
})
aiProvider = service.NewDifyAdapter(difyClient)
logger.Logger.Info("Using Dify as AI provider")
} else {
llmService := service.NewLLMService(
miniMaxAPIURL,
miniMaxAPIKey,
miniMaxModel,
qwenAPIURL,
qwenAPIKey,
qwenModel,
)
aiProvider = llmService
logger.Logger.Info("Using LLM (MiniMax) as AI provider")
}
// 创建 Service 层实例
personaService := service.NewPersonaService(personaRepo)
memoryService := service.NewMemoryService(shortTermMemoryRepo, longTermMemoryRepo)
auditService := service.NewAuditService()
chatService := service.NewChatService(
llmService,
aiProvider,
personaService,
memoryService,
auditService,

View File

@ -160,39 +160,29 @@ func (p *AIChatProvider) SendMessage(ctx context.Context, req *pb.ChatMessageReq
}
// 7. 调用大模型(流式)
streamReader, err := p.chatService.LLMService.StreamChat(ctx, messages)
streamReader, err := p.chatService.StreamChat(ctx, messages)
if err != nil {
logger.Logger.Error("LLM call failed", zap.Error(err))
logger.Logger.Error("AI call failed", zap.Error(err))
// 检查是否是敏感内容错误
if _, ok := err.(*service.SensitiveContentError); ok {
logger.Logger.Info("Content blocked by MiniMax safety filter, trying backup model")
// 尝试备用模型
streamReader, err = p.chatService.LLMService.StreamChatWithBackup(ctx, messages)
if err != nil {
// 备用模型也失败
logger.Logger.Error("Backup model also failed", zap.Error(err))
stream.Send(&pb.ChatMessageResponse{
Content: p.auditService.DefaultSafeResponse(),
SessionId: sessionID,
IsEnd: true,
})
return nil
}
} else {
// 其他错误,尝试备用模型
streamReader, err = p.chatService.LLMService.StreamChatWithBackup(ctx, messages)
if err != nil {
logger.Logger.Error("Backup model also failed", zap.Error(err))
stream.Send(&pb.ChatMessageResponse{
Content: "抱歉,服务暂时不可用",
SessionId: sessionID,
IsEnd: true,
Error: err.Error(),
})
return err
}
logger.Logger.Info("Content blocked by safety filter")
stream.Send(&pb.ChatMessageResponse{
Content: p.auditService.DefaultSafeResponse(),
SessionId: sessionID,
IsEnd: true,
})
return nil
}
// 其他错误
stream.Send(&pb.ChatMessageResponse{
Content: "抱歉,服务暂时不可用",
SessionId: sessionID,
IsEnd: true,
Error: err.Error(),
})
return err
}
defer streamReader.Close()

View File

@ -6,9 +6,14 @@ import (
"github.com/topfans/backend/services/aiChatService/model"
)
// AIProvider 流式对话接口实现类LLMService、DifyAdapter
type AIProvider interface {
StreamChat(ctx context.Context, messages []model.Message) (StreamReader, error)
}
// ChatService 对话服务
type ChatService struct {
LLMService *LLMService
aiProvider AIProvider
personaService *PersonaService
memoryService *MemoryService
auditService *AuditService
@ -18,7 +23,7 @@ type ChatService struct {
// NewChatService 创建对话服务
func NewChatService(
llmService *LLMService,
aiProvider AIProvider,
personaService *PersonaService,
memoryService *MemoryService,
auditService *AuditService,
@ -26,7 +31,7 @@ func NewChatService(
triggerTurns int,
) *ChatService {
return &ChatService{
LLMService: llmService,
aiProvider: aiProvider,
personaService: personaService,
memoryService: memoryService,
auditService: auditService,
@ -57,4 +62,9 @@ func (s *ChatService) ExtractMemory(ctx context.Context, userID int64, recentMes
func (s *ChatService) GetWelcomeMessage(sessionID string, userID int64, starID int64) string {
// 默认欢迎消息
return "亲爱的你来辣 ~~"
}
// StreamChat 流式对话(委托给 aiProvider
func (s *ChatService) StreamChat(ctx context.Context, messages []model.Message) (StreamReader, error) {
return s.aiProvider.StreamChat(ctx, messages)
}

View File

@ -0,0 +1,70 @@
package service
import (
"context"
"fmt"
"io"
"github.com/topfans/backend/services/aiChatService/model"
)
// DifyStreamReaderAdapter 适配 DifyStreamReader → StreamReader 接口
// DifyStreamReader.Next() returns (content, done, convID)
// StreamReader.Next() returns (content, done, error)
type DifyStreamReaderAdapter struct {
reader *DifyStreamReader
}
func (a *DifyStreamReaderAdapter) Next() (string, bool, error) {
content, done, _ := a.reader.Next()
return content, done, nil
}
func (a *DifyStreamReaderAdapter) Close() error {
return a.reader.Close()
}
// DifyAdapter Dify 适配器:适配 DifyClient → StreamReader 接口
// 将 BuildPrompt 输出的 []model.Message 转换为 Dify StreamRequest
type DifyAdapter struct {
difyClient *DifyClient
conversationID string
}
// NewDifyAdapter 创建 Dify 适配器
func NewDifyAdapter(difyClient *DifyClient) *DifyAdapter {
return &DifyAdapter{
difyClient: difyClient,
}
}
// StreamChat 将 []model.Message 转换为 Dify 流式调用
// messages 的最后一条必须是 user 消息(当前输入)
// Dify Workflow 端点不维护多轮上下文,需要通过 inputs.query 传入完整对话文本
func (s *DifyAdapter) StreamChat(ctx context.Context, messages []model.Message) (StreamReader, error) {
if len(messages) == 0 {
return nil, fmt.Errorf("empty messages")
}
// 取最后一条作为当前 query
lastMsg := messages[len(messages)-1]
if lastMsg.Role != "user" {
return nil, fmt.Errorf("last message must be user role")
}
req := StreamRequest{
Query: lastMsg.Content,
ConversationID: s.conversationID,
}
reader, err := s.difyClient.StreamChat(ctx, req)
if err != nil {
return nil, err
}
return &DifyStreamReaderAdapter{reader: reader}, nil
}
// 确保接口实现正确
var _ StreamReader = (*DifyStreamReaderAdapter)(nil)
var _ io.Closer = (*DifyStreamReaderAdapter)(nil)

View File

@ -0,0 +1,213 @@
package service
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/topfans/backend/pkg/logger"
"go.uber.org/zap"
)
// DifyConfig Dify 客户端配置
// MVP 阶段:直接从环境变量读
// Stage 2+:移到 ai_chat_configs 数据库
type DifyConfig struct {
APIBase string
APIKey string
TimeoutSec int
}
// DifyClient Dify HTTP 客户端MVP 唯一 AI 源)
// ★ 简化:只调 Workflow /v1/workflows/run 流式接口
// 不做重试循环V1 Network 错误重试 1 次MVP 失败直接报错)
// 不做滑动窗口审计V1 AuditService 逐 token 拦截已足够)
type DifyClient struct {
apiBase string
apiKey string
httpClient *http.Client
}
// NewDifyClient 创建 Dify 客户端
func NewDifyClient(cfg DifyConfig) *DifyClient {
return &DifyClient{
apiBase: cfg.APIBase,
apiKey: cfg.APIKey,
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
}
}
// StreamRequest 流式调用入参
type StreamRequest struct {
Query string
ConversationID string
}
// StreamChat 流式调用 Dify Workflow
// 输入:用户消息 + Dify conversation_id首次为空
// 输出:返回 (DifyStreamReader, error)
func (c *DifyClient) StreamChat(ctx context.Context, req StreamRequest) (*DifyStreamReader, error) {
body := map[string]interface{}{
"inputs": map[string]string{"query": req.Query},
"response_mode": "streaming",
"conversation_id": req.ConversationID,
"user": "aichat-mvp",
}
jsonData, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("dify request marshal: %w", err)
}
url := c.apiBase + "/workflows/run"
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("dify request new: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("dify request: %w", err)
}
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("dify returned HTTP %d: %s", resp.StatusCode, string(respBody))
}
logger.Logger.Info("Dify stream started", zap.String("url", url))
return newDifyStreamReader(resp.Body), nil
}
// ============================================================
// DifyStreamReader解析 SSE 流
// ============================================================
// newDifyStreamReader 创建流式读取器
func newDifyStreamReader(body io.ReadCloser) *DifyStreamReader {
scanner := bufio.NewScanner(body)
// 增大 buffer 防止 Dify 流式 chunk 过大被截断
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
return &DifyStreamReader{
body: body,
conversationID: "",
closed: false,
scanner: scanner,
}
}
// DifyStreamReader Dify SSE 流式读取器
// 简化版本:用 bufio.Scanner 按行读 data: 字段
type DifyStreamReader struct {
body io.ReadCloser
conversationID string
closed bool
scanner *bufio.Scanner
}
// Next 读下一条数据
// 返回 (content, done, convID)
// - content: 当前 token流式
// - done: 流是否结束
// - convID: 流结束后从 convID 读 Dify conversation_id
func (r *DifyStreamReader) Next() (content string, done bool, convID string) {
if r.closed {
return "", true, r.conversationID
}
for r.scanner.Scan() {
line := strings.TrimSpace(r.scanner.Text())
if line == "" {
continue
}
if !strings.HasPrefix(line, "data: ") {
continue
}
payload := strings.TrimPrefix(line, "data: ")
if payload == "[DONE]" {
r.closed = true
return "", true, r.conversationID
}
// ★ MVP 修复Dify Workflow 端点 (/v1/workflows/run) 的事件格式
// - text_chunk: 流式分块text 字段是内容
// - workflow_finished: 结束事件data.outputs.text 是完整内容
// - 没有 conversation_idWorkflow 端点不维护 conversation_id
// ★ 与 Chatflow 端点 (/v1/chat-messages) 不同:
// - Chatflow 用 event=message, answer 字段
// - Workflow 用 event=text_chunk, text 字段
var event struct {
Event string `json:"event"`
Text string `json:"text"` // Chatflow: 顶层 text
ConversationID string `json:"conversation_id"`
Message string `json:"message"`
Data struct {
Text string `json:"text"` // Workflow: data.text
} `json:"data"`
}
if err := json.Unmarshal([]byte(payload), &event); err != nil {
continue
}
// 记录 conversation_id仅 Chatflow 有Workflow 端点通常无)
if event.ConversationID != "" && r.conversationID == "" {
r.conversationID = event.ConversationID
}
text := event.Data.Text
if text == "" {
text = event.Text
}
switch event.Event {
case "text_chunk":
if text != "" {
return text, false, ""
}
case "message":
if text != "" {
return text, false, ""
}
case "workflow_finished":
// ★ MVP 关键Dify Workflow 结束事件
r.closed = true
return "", true, r.conversationID
case "message_end":
// Chatflow 端点兼容
r.closed = true
return "", true, r.conversationID
case "error":
logger.Logger.Error("Dify stream error event", zap.String("message", event.Message))
r.closed = true
return "", true, r.conversationID
}
}
// scanner 结束(流关闭或出错)
r.closed = true
if err := r.scanner.Err(); err != nil {
logger.Logger.Warn("Dify stream scan error", zap.Error(err))
}
return "", true, r.conversationID
}
// Close 关闭流
func (r *DifyStreamReader) Close() error {
if r.closed {
return nil
}
r.closed = true
return r.body.Close()
}
// GetConversationID 获取 Dify 返回的 conversation_id
// 仅在 Next() 返回 done=true 后调用
func (r *DifyStreamReader) GetConversationID() string {
return r.conversationID
}

View File

@ -50,4 +50,11 @@ SMS_SIGN_NAME=上海顶粉数字科技
SMS_TEMPLATE_CODE=SMS_314621237
SMS_REGION=cn-hangzhou
# ==================== Dify Configuration ====================
# 服务器上部署的 Dify 服务101.132.250.62nginx 转发到 Dify API 容器 5001
# DIFY_API_BASE: Dify 服务的 API 地址v1 是 API 前缀)
# DIFY_API_KEY: 在 Dify 控制台「工作室」→「角角」→「后端服务 API」→「API 密钥」获取,格式: app-xxxxxxxxxxxx
DIFY_API_BASE=http://101.132.250.62:8083/v1
DIFY_API_KEY=app-iCsnp0R2jJppKdmrpoeOxEfL

View File

@ -172,7 +172,7 @@ build_service() {
-f "$DOCKERFILE" \
--target "$docker_target" \
--platform "$TARGET_ARCH" \
-t "${IMAGE_PREFIX}/${service_name}:latest" \
-t "${IMAGE_PREFIX}/${docker_target}:latest" \
../
if [ $? -eq 0 ]; then

391
docker/dify-deploy.sh Normal file
View File

@ -0,0 +1,391 @@
#!/bin/bash
# ===================================================================
# TopFans Dify 部署脚本
# 功能:服务器上安装 Dify + 配置环境变量
# ===================================================================
#
# 使用前提:
# 1. 服务器已配置 SSH 免密登录
# 2. 服务器有足够的内存(推荐 4GB+
#
# 使用方式:
# ./dify-deploy.sh install # 安装 Dify 到服务器
# ./dify-deploy.sh status # 查看 Dify 服务状态
# ./dify-deploy.sh logs # 查看 Dify 日志
# ./dify-deploy.sh restart # 重启 Dify
# ./dify-deploy.sh upgrade # 升级 Dify 版本
# ./dify-deploy.sh uninstall # 卸载 Dify
# ./dify-deploy.sh show-config # 显示需要配置到 TopFans 的环境变量
#
# ===================================================================
set -e
# ==================== 颜色定义 ====================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m'
# ==================== 路径配置 ====================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ==================== 服务器配置 ====================
SERVER_HOST="101.132.250.62"
SERVER_PORT="22"
SERVER_USER="root"
SERVER_PATH="/opt/dify/docker"
# ==================== Dify 配置 ====================
# 端口说明:
# EXPOSE_NGINX_PORT: 对外统一入口nginxTopFans DIFY_API_BASE 走这个
# CONSOLE_WEB_PORT: Dify 管理控制台(浏览器手动访问)
# APP_WEB_PORT: Dify API 容器端口5001内部用外部不直接连
# 默认值对齐 docker/.env.prod 中 DIFY_API_BASE=http://101.132.250.62:8083/v1
DIFY_NGINX_PORT="8083" # Dify Web/API 统一入口nginx
DIFY_CONSOLE_PORT="8084" # Dify 控制台
DIFY_API_PORT="8085" # Dify API 容器直连(内部)
# 密码与密钥留空install 时由 openssl rand 生成强随机值
# (避免硬编码弱密码,所有 Dify 实例使用不同密钥)
DIFY_DB_PASSWORD=""
DIFY_REDIS_PASSWORD=""
# ==================== SSH 命令 ====================
ssh_cmd() {
ssh -o StrictHostKeyChecking=no -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "$@"
}
# ==================== docker-compose 命令检测 ====================
# 兼容 v1 二进制与 v2 插件(v2 在新版 Docker 默认安装,但 v1 命令不存在)
# 优先用 v1;没有则检查 v2 plugin 并建软链伪装成 v1参考 deploy.sh:341
dc_cmd() {
if ssh_cmd "command -v docker-compose" &>/dev/null; then
echo "docker-compose"
return 0
fi
if ssh_cmd "docker compose version" &>/dev/null; then
# v2 插件存在,建软链伪装成 v1
ssh_cmd "ln -sf /usr/libexec/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose 2>/dev/null" || true
fi
if ! ssh_cmd "command -v docker-compose" &>/dev/null; then
print_msg "$RED" "❌ docker-compose 未安装且 v2 插件建软链失败"
return 1
fi
echo "docker-compose"
}
# ==================== 打印函数 ====================
print_step() {
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} $1${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
print_msg() {
local color=$1
local msg=$2
echo -e "${color}${msg}${NC}"
}
# ==================== 帮助信息 ====================
show_help() {
cat << EOF
${MAGENTA}TopFans Dify 部署脚本${NC}
${YELLOW}命令:${NC}
${GREEN}install${NC} 安装 Dify 到服务器(首次部署)
${GREEN}status${NC} 查看 Dify 服务状态
${GREEN}logs${NC} 查看 Dify 日志
${GREEN}restart${NC} 重启 Dify
${GREEN}upgrade${NC} 升级 Dify 版本
${GREEN}uninstall${NC} 卸载 Dify
${GREEN}show-config${NC} 显示需要配置到 TopFans 的环境变量
${YELLOW}前提准备:${NC}
1. 服务器已配置 SSH 密钥登录
2. 服务器内存 >= 4GB
${YELLOW}注意事项:${NC}
- Dify 使用独立端口(不与 TopFans 冲突)
- Dify 使用独立的 PostgreSQL 和 Redis
- Workflow 需要在 Dify 控制台手动导入
EOF
}
# ==================== 1. 安装 Dify ====================
do_install() {
print_step "🚀 安装 Dify 到服务器"
print_msg "$YELLOW" "目标服务器: ${SERVER_USER}@${SERVER_HOST}:${SERVER_PORT}"
print_msg "$YELLOW" "安装路径: ${SERVER_PATH}"
echo ""
# 检查服务器 Docker 环境
print_msg "$YELLOW" "检查 Docker 环境..."
local docker_version=$(ssh_cmd "docker --version 2>/dev/null || echo 'NOT_INSTALLED'")
print_msg "$GREEN" "Docker: ${docker_version}"
# 如果 Docker 未安装,先安装
if [ "$docker_version" = "NOT_INSTALLED" ]; then
print_msg "$YELLOW" "Docker 未安装,开始安装..."
ssh_cmd "curl -fsSL https://get.docker.com | sh"
ssh_cmd "systemctl start docker && systemctl enable docker"
print_msg "$GREEN" "✅ Docker 安装完成"
fi
# 检查 docker-compose
local compose_version=$(ssh_cmd "docker-compose --version 2>/dev/null || docker compose version 2>/dev/null || echo 'NOT_INSTALLED'")
print_msg "$GREEN" "docker-compose: ${compose_version}"
# 创建服务器目录
print_msg "$YELLOW" "创建目录..."
ssh_cmd "mkdir -p ${SERVER_PATH}"
print_msg "$GREEN" "✅ 目录创建完成"
# 检测并设置 docker-compose 命令(v1/v2 兼容)
local DC
DC=$(dc_cmd) || { print_msg "$RED" "❌ docker-compose 不可用,请先安装 Docker"; return 1; }
# 保护已存在的 .env:重跑 install 时保留用户配置(SECRET_KEY/管理员密码等)
if ssh_cmd "[ -f ${SERVER_PATH}/.env ]"; then
print_msg "$YELLOW" "⚠️ 检测到已存在的 .env,跳过下载和配置"
print_msg "$YELLOW" " 如需重新配置,请先备份 ${SERVER_PATH}/.env 后删除"
else
# 下载 Dify docker-compose
print_msg "$YELLOW" "下载 Dify docker-compose..."
ssh_cmd "cd ${SERVER_PATH} && \
curl -L https://raw.githubusercontent.com/langgenius/dify/main/docker/docker-compose.yaml \
-o docker-compose.yml 2>/dev/null || \
curl -L https://raw.githubusercontent.com/langgenius/dify/v0.15.0/docker/docker-compose.yaml \
-o docker-compose.yml"
print_msg "$GREEN" "✅ docker-compose.yml 下载完成"
# 下载 .env 示例文件
print_msg "$YELLOW" "下载 .env 配置..."
ssh_cmd "cd ${SERVER_PATH} && \
curl -L https://raw.githubusercontent.com/langgenius/dify/main/docker/.env.example \
-o .env 2>/dev/null || \
curl -L https://raw.githubusercontent.com/langgenius/dify/v0.15.0/docker/.env.example \
-o .env"
print_msg "$GREEN" "✅ .env 下载完成"
# 修改 .env 配置(端口、密码等)
# ★ heredoc delimiter 不可加单引号,否则 \$VAR 不展开
print_msg "$YELLOW" "配置 Dify 环境变量..."
ssh_cmd "cat > ${SERVER_PATH}/.env.custom << ENVEOF
# 端口配置(避免与 TopFans 冲突)
EXPOSE_NGINX_PORT=${DIFY_NGINX_PORT}
CONSOLE_WEB_PORT=${DIFY_CONSOLE_PORT}
APP_WEB_PORT=${DIFY_API_PORT}
# 数据库配置
DB_USERNAME=postgres
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=dify
POSTGRES_PASSWORD=\$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
# Redis 配置
REDIS_PASSWORD=\$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
# API 配置(Dify 官方要求 SECRET_KEY 至少 42 字符)
SECRET_KEY=\$(openssl rand -base64 42)
INIT_SECRET_KEY=\$(openssl rand -base64 42)
ENVEOF
"
ssh_cmd "cd ${SERVER_PATH} && cat .env .env.custom > .env.tmp && mv .env.tmp .env && rm -f .env.custom"
print_msg "$GREEN" "✅ 环境变量配置完成"
fi
# 启动 Dify
print_msg "$YELLOW" "启动 Dify 服务(首次启动需要几分钟)..."
ssh_cmd "cd ${SERVER_PATH} && ${DC} up -d"
# 等待服务启动
print_msg "$YELLOW" "等待服务启动(最多 120 秒)..."
local count=0
while [ $count -lt 120 ]; do
local api_status=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://localhost:${DIFY_API_PORT}/health 2>/dev/null || echo '000'")
if [ "$api_status" = "200" ]; then
print_msg "$GREEN" "✅ Dify API 就绪"
break
fi
sleep 5
count=$((count + 5))
echo -n "."
done
echo ""
print_step "📊 Dify 安装结果"
print_msg "$GREEN" "✅ Dify 安装完成!"
echo ""
print_msg "$CYAN" "访问地址:"
print_msg "$YELLOW" " 控制台: http://${SERVER_HOST}:${DIFY_CONSOLE_PORT}"
print_msg "$YELLOW" " Web: http://${SERVER_HOST}:${DIFY_NGINX_PORT}"
print_msg "$YELLOW" " API: http://${SERVER_HOST}:${DIFY_API_PORT}"
echo ""
print_msg "$RED" "⚠️ 首次访问需要创建管理员账号!"
echo ""
print_msg "$CYAN" "下一步:"
print_msg "$YELLOW" " 1. 访问控制台 http://${SERVER_HOST}:${DIFY_CONSOLE_PORT},创建管理员账号"
print_msg "$YELLOW" " 2. 在控制台导入 Workflowdocs/dify/*.yml"
print_msg "$YELLOW" " 3. 运行 ./dify-deploy.sh show-config 查看 TopFans 配置"
}
# ==================== 2. 查看 Dify 状态 ====================
do_status() {
print_step "📊 Dify 服务状态"
local DC
DC=$(dc_cmd) || return 1
ssh_cmd "cd ${SERVER_PATH} && ${DC} ps 2>/dev/null || echo 'Dify 未安装或未启动'"
echo ""
# 健康检查
local api_status=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://localhost:${DIFY_API_PORT}/health 2>/dev/null || echo '000'")
if [ "$api_status" = "200" ]; then
print_msg "$GREEN" "✅ Dify API 健康"
else
print_msg "$RED" "❌ Dify API 异常 (HTTP ${api_status})"
fi
}
# ==================== 3. 查看日志 ====================
do_logs() {
print_step "📋 Dify 日志"
print_msg "$YELLOW" "按 Ctrl+C 退出日志"
echo ""
local DC
DC=$(dc_cmd) || return 1
ssh_cmd "cd ${SERVER_PATH} && ${DC} logs -f 2>/dev/null || echo 'Dify 未安装'"
}
# ==================== 4. 重启 ====================
do_restart() {
print_step "🔄 重启 Dify"
print_msg "$YELLOW" "正在重启..."
local DC
DC=$(dc_cmd) || return 1
ssh_cmd "cd ${SERVER_PATH} && ${DC} restart 2>/dev/null"
sleep 10
do_status
}
# ==================== 5. 升级 ====================
do_upgrade() {
print_step "⬆️ 升级 Dify"
print_msg "$RED" "警告: 升级前建议备份数据!"
read -p "确认升级? (y/N): " confirm < /dev/tty
if [ "$confirm" != "y" ]; then
print_msg "$YELLOW" "已取消"
exit 0
fi
local DC
DC=$(dc_cmd) || return 1
print_msg "$YELLOW" "下载最新 docker-compose..."
ssh_cmd "cd ${SERVER_PATH} && \
curl -L https://raw.githubusercontent.com/langgenius/dify/main/docker/docker-compose.yaml \
-o docker-compose.yml"
print_msg "$YELLOW" "执行升级..."
ssh_cmd "cd ${SERVER_PATH} && ${DC} up -d"
print_msg "$GREEN" "✅ 升级完成"
}
# ==================== 6. 卸载 ====================
do_uninstall() {
print_step "🗑️ 卸载 Dify"
print_msg "$RED" "警告: 此操作将删除所有 Dify 数据!"
read -p "确认卸载? (输入 'YES' 确认): " confirm < /dev/tty
if [ "$confirm" != "YES" ]; then
print_msg "$YELLOW" "已取消"
exit 0
fi
local DC
DC=$(dc_cmd) || return 1
print_msg "$YELLOW" "停止并删除容器..."
ssh_cmd "cd ${SERVER_PATH} && ${DC} down -v 2>/dev/null || true"
print_msg "$YELLOW" "删除数据卷..."
ssh_cmd "docker volume rm \$(docker volume list -q -f name=dify) 2>/dev/null || true"
print_msg "$GREEN" "✅ Dify 卸载完成"
}
# ==================== 7. 显示 TopFans 配置 ====================
show_topfans_config() {
print_step "📝 TopFans 后端配置"
print_msg "$CYAN" "需要在 /opt/topfans/docker/.env.prod 中配置以下环境变量:"
echo ""
print_msg "$YELLOW" "# ========== Dify 配置 =========="
print_msg "$YELLOW" "# Dify API 地址(走 nginx 统一入口 ${DIFY_NGINX_PORT},与 Dify 容器内 APP_WEB_PORT 不同)"
print_msg "$CYAN" "DIFY_API_BASE=http://${SERVER_HOST}:${DIFY_NGINX_PORT}/v1"
echo ""
print_msg "$YELLOW" "# Dify API Key从 Dify 控制台获取)"
print_msg "$CYAN" "DIFY_API_KEY=app-你的APIKey"
echo ""
print_msg "$RED" "⚠️ 配置完成后需要重启 TopFans 服务:"
print_msg "$RED" " ./deploy.sh restart --server ${SERVER_HOST}"
}
# ==================== 主函数 ====================
main() {
if [ $# -eq 0 ]; then
show_help
exit 0
fi
local command=$1
shift
case $command in
install)
do_install
;;
status)
do_status
;;
logs)
do_logs
;;
restart)
do_restart
;;
upgrade)
do_upgrade
;;
uninstall)
do_uninstall
;;
show-config)
show_topfans_config
;;
-h|--help|help)
show_help
;;
*)
print_msg "$RED" "错误: 未知命令 '$command'"
show_help
exit 1
;;
esac
}
main "$@"

227
docs/dify/角角.yml Normal file
View File

@ -0,0 +1,227 @@
app:
description: ''
icon: 🤖
icon_background: '#FFEAD5'
icon_type: emoji
mode: workflow
name: 角角
use_icon_as_answer_icon: false
dependencies:
- current_identifier: null
type: marketplace
value:
marketplace_plugin_unique_identifier: langgenius/minimax:0.0.21@ccfbb9b4f38d35b1daa2483daaad5ed5402896d1119d68601a820a806910bf87
version: null
kind: app
version: 0.6.0
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload:
allowed_file_extensions:
- .JPG
- .JPEG
- .PNG
- .GIF
- .WEBP
- .SVG
allowed_file_types:
- image
allowed_file_upload_methods:
- local_file
- remote_url
enabled: false
fileUploadConfig:
attachment_image_file_size_limit: 2
audio_file_size_limit: 50
batch_count_limit: 5
file_size_limit: 15
file_upload_limit: 20
image_file_batch_limit: 10
image_file_size_limit: 10
single_chunk_attachment_limit: 10
video_file_size_limit: 100
workflow_file_upload_limit: 10
image:
enabled: false
number_limits: 3
transfer_methods:
- local_file
- remote_url
number_limits: 3
opening_statement: ''
retriever_resource:
enabled: true
sensitive_word_avoidance:
enabled: false
speech_to_text:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
language: ''
voice: ''
graph:
edges:
- data:
isInIteration: false
isInLoop: false
sourceType: start
targetType: knowledge-retrieval
id: 1782726878899-source-1782726916693-target
source: '1782726878899'
sourceHandle: source
target: '1782726916693'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: knowledge-retrieval
targetType: llm
id: 1782726916693-source-1782726955948-target
source: '1782726916693'
sourceHandle: source
target: '1782726955948'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: llm
targetType: end
id: 1782726955948-source-1782727496103-target
source: '1782726955948'
sourceHandle: source
target: '1782727496103'
targetHandle: target
type: custom
zIndex: 0
nodes:
- data:
selected: false
title: 用户输入
type: start
variables:
- default: ''
hint: ''
label: query
options: []
placeholder: ''
required: true
type: text-input
variable: query
height: 108
id: '1782726878899'
position:
x: 16
y: 277
positionAbsolute:
x: 16
y: 277
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
dataset_ids:
- NCv1DfzNsC8G/IjCISUkvry7LBfVIV8mXqRliZH+TUgfixw/U0t6Tz5wWsLdX/Vh
multiple_retrieval_config:
reranking_enable: false
reranking_mode: reranking_model
top_k: 4
query_attachment_selector: []
query_variable_selector:
- '1782726878899'
- query
retrieval_mode: multiple
selected: false
title: 知识检索
type: knowledge-retrieval
height: 89
id: '1782726916693'
position:
x: 230
y: 103.1999999999999
positionAbsolute:
x: 230
y: 103.1999999999999
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
context:
enabled: true
variable_selector:
- '1782726916693'
- result
model:
completion_params:
temperature: 0.7
mode: chat
name: minimax-m3
provider: langgenius/minimax/minimax
prompt_config:
jinja2_variables: []
prompt_template:
- edition_type: basic
id: b656505c-e726-47c4-b036-6e6025595a8c
role: system
text: 你是角角,专注于肖战的粉丝助手
- id: db882835-3ffe-4600-b95a-9eca51b93ce2
role: user
text: '{{#context#}}}{{#1782726878899.query#}}}'
selected: false
title: LLM
type: llm
vision:
enabled: false
height: 87
id: '1782726955948'
position:
x: 358
y: 510
positionAbsolute:
x: 358
y: 510
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
outputs:
- value_selector:
- '1782726955948'
- text
value_type: string
variable: text
selected: false
title: 输出
type: end
height: 88
id: '1782727496103'
position:
x: 612
y: 235
positionAbsolute:
x: 612
y: 235
selected: true
sourcePosition: right
targetPosition: left
type: custom
width: 242
viewport:
x: 19
y: -99.1999999999999
zoom: 1
rag_pipeline_variables: []

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,939 @@
# AI 搭子 MVP 方案
> **本文档是实施级方案**。所有"为未来 100 明星 + 多 AI 平台"准备的复杂设计Provider 抽象、ProviderFactory、Pipeline、ConversationStore 抽象等)**MVP 阶段不实现**。
>
> V2 完整架构文档([2026-06-29-ai-chat-dify-integration-v2-design.md](2026-06-29-ai-chat-dify-integration-v2-design.md))保留为**长期演进路线图**MVP 阶段不实施。
> **🎉 2026-06-30 MVP 端到端跑通!**
>
> 验证结果(实测):
> - ✅ 后端 20008 端口正常 Dubbo 监听
> - ✅ Dify Workflow 流式响应 8 个 chunk 正常返回
> - ✅ Dify 内部用 minimax-m3 模型推理("嗨!我是角角~..." 完整回复)
> - ✅ ai_conversations 表写入 message_count=2
> - ✅ ai_messages 表写入 user="hi" + assistant="嗨!我是角角~..." (228 字符)
> - ✅ Gateway → aichatservice → Dify → DB 完整链路打通
---
## 〇、我们解决的问题
### .1 业务问题
追星 App 用户与"角角"AI 搭子聊天时AI **没有专属知识库**,无法针对所追明星(默认肖战)给出有针对性的回答:
- 用户:"肖战最近有什么新作品?"
- 现状AI 只能泛泛而谈("肖战是中国男演员..."
- 期望AI 应能基于最新资料回答"根据知识库肖战的新剧《X》将于 X 月上映"
### .2 技术问题
| # | 问题 | 严重度 | MVP 解决方式 |
|---|------|--------|------------|
| 1 | **缺乏 RAG 能力**AI 没有专属数据 | 🔴 高 | 接入 Dify Workflow + 肖战知识库 |
| 2 | **会话无持久化**:当前仅 Redis 缓存 24h | 🟡 中 | PostgreSQL 持久化 + Redis 缓存(★ 核心) |
| 3 | **AI 平台耦合**:未来要接 Coze/FastGPT | 🟢 低 | MVP 不做1 个 Dify 够用 |
| 4 | **运营成本失控**100 明星时配置爆炸 | 🟢 低 | MVP 固定 1 个星 |
### .3 解决方式(**业务驱动**,不是架构驱动)
| 阶段 | 做什么 | 不做什么 | 触发条件 |
|------|--------|---------|---------|
| **MVP** | Dify + PostgreSQL + Redis | Provider 抽象、Fallback、多星 | 现在 |
| Stage 2 | 多星 Dataset 切换 | Provider 抽象 | 加第 2 个星 / 用户 > 1000 |
| Stage 3 | MiniMax fallback | 抽象 | Dify 偶发故障 |
| Stage 4 | Provider 抽象 | 复杂 Pipeline | 接第 2 个 AI 平台 / 用户 > 10万 |
| Stage 5 | Pipeline / AIProfile | A/B 测试 / 灰度 | 用户 > 100万 |
**核心原则****业务不到不做架构**。MVP 阶段不实施"为未来 100 明星 + 多 AI 平台"准备的复杂设计。
---
## 〇、文档说明
- **MVP 范围**:只有 1 个明星(默认肖战 star_id=87
- **AI 平台**:只有 Dify无 MiniMax fallback无 Provider 抽象)
- **目标**:验证业务假设(用户愿意与"角角"聊天)
- **后续演进**:见 [§十 演进路径](#十演进路径)
---
## .1 方案概述(★ 必读)
### .1.1 我们要做什么
**业务**:在追星 App 里加"AI 搭子"功能。用户进入后默认看到"角角"(肖战的 AI 形象),可以问"肖战最近在干嘛?"等关于肖战的问题。
**MVP 范围**
- ✅ 1 个明星(肖战)
- ✅ 1 个 Dify Workflow含 1 个肖战知识库)
- ✅ 1 种回复源Dify
- ✅ WebSocket 流式输出
- ❌ 不做:多星切换、模型 fallback、人设自定义、长期记忆提取、多 AI 平台
### .1.2 整体实现路径
```
数据迁移 Dify 端准备 后端代码 联调测试
├ 建 2 张表 ├ 准备知识库 ├ model ├ 内部账号测试
├ ├ 创 Workflow ├ repository ├ WebSocket 联调
└ └ 不需要 Code 节点 └ service └ 错误注入
└ provider
└ main.go 装配
```
**不要分配实施时间**——按业务节奏推进。
### .1.3 关键决策80% 推到 Stage 2+
| 决策项 | MVP 选择 | Stage 2+ 再考虑 |
|--------|---------|-----------------|
| Dify 架构 | **Workflow**(含 1 个 Dataset | 多星时加 dataset 变量 |
| AI Provider | **Dify 单源**(无抽象) | Stage 3 才抽象 `AIProvider` |
| 会话存储 | **PostgreSQL 主 + Redis 缓存** | 沿用 MVP 设计 |
| Fallback | **无**Dify 失败就报错) | Stage 2 看需求 |
| Memory 提取 | **不做**(每轮都入 ai_messages | Stage 2+ 看需求 |
| Provider 抽象 | **不做** | Stage 3+ |
| 数据集切换 | **固定 1 个**(肖战) | Stage 2 多星时加 mapping |
### .1.4 核心架构图TL;DR
```
用户(追星 App
│ WebSocket
Gateway (现有, 不改)
│ Dubbo Triple
AIChatService.ChatService
├─ JWT 鉴权 (从 Dubbo attachments 取 user_id)
├─ 保存/读取会话: ConversationRepository
│ ├─ ai_conversations (PostgreSQL, ★ V2 关键决策保留)
│ └─ ai_messages (PostgreSQL, ★ V2 关键决策保留)
├─ 调 Dify (★ 唯一 AI 源)
│ └─ DifyClient.StreamChat()
│ └─ POST /v1/workflows/run
└─ SSE 流 → WebSocket → 客户端
外部: Dify Workflow
┌──────────────────────┐
│ 开始 │
│ ↓ │
│ Knowledge Retrieval │ ← 固定查"肖战知识库"
│ ↓ │
│ LLM │ ← 固定 Prompt (角角人设)
│ ↓ │
│ 结束 │
└──────────────────────┘
```
**关键简化**
- ❌ 没有 Provider 抽象
- ❌ 没有 ProviderFactory
- ❌ 没有 MemoryStore
- ❌ 没有 RedisLock并发问题 MVP 阶段不严重)
- ❌ 没有 AIProfile
- ❌ 没有 DatasetResolver
- ❌ 没有 Star→Dataset mapping只有 1 个星)
- ❌ 没有 Fallback 逻辑
- ❌ 没有 Memory 提取循环
- ❌ 没有 Star App 多 Workflow
---
## 一、MVP 范围
### 1.1 包含
- ✅ 1 个明星(肖战 star_id=87
- ✅ 1 个 Dify Workflow3 节点)
- ✅ 1 个 Dify Dataset肖战专属知识库
- ✅ WebSocket 协议(沿用现有)
- ✅ JWT 鉴权(沿用现有)
- ✅ Audit 前置 + 后置(沿用现有 AuditService
- ✅ Conversation 持久化到 PostgreSQL**核心决策MVP 即落地**
- ✅ Redis 缓存(沿用现有)
### 1.2 不包含Stage 2+ 再做)
- ❌ 多星切换(用户不能选其他明星)
- ❌ MiniMax fallbackDify 失败就报错给用户)
- ❌ Persona 自定义(人设固定"角角"
- ❌ 长期记忆提取(不分析对话提取记忆)
- ❌ Provider 抽象接口(直接调 DifyClient
- ❌ 人设/风格/语言/记忆等参数化(都写死在 Dify Prompt 里)
- ❌ Dify 内容审核节点AuditService 已拦截MVP 阶段够用)
- ❌ Datasets 动态切换(固定"肖战知识库"
---
## 二、整体架构
### 2.1 数据流(一次完整对话)
```
[Mobile]
│ WebSocket send {action: "send_message", session_id, message}
[Gateway Hub]
│ 鉴权 (JWT → user_id)
[AIChatService Provider.SendMessage]
├─ 1. 前置审核 (AuditService.AuditText) ★ 现有代码
├─ 2. 获取/创建会话 (ConversationRepository)
│ ├─ PostgreSQL ai_conversations (★ V2 关键)
│ └─ Redis 缓存 1h (现有代码)
├─ 3. 调 Dify (★ MVP 唯一 AI 源)
│ └─ DifyClient.StreamChat()
│ └─ POST /v1/workflows/run
├─ 4. 流式返回 + 逐 token 后置审核 (AuditService.AuditResponse)
│ └─ ★ 现有代码
├─ 5. 保存消息 (ConversationRepository)
│ └─ PostgreSQL ai_messages (★ V2 关键)
└─ 6. (Stage 2+ 才做) 记忆提取
```
### 2.2 关键简化点
| 维度 | V2 文档 | MVP 实际 |
|------|--------|---------|
| 核心业务逻辑 | 9+ 步骤 | **4 步骤**(审计/会话/Dify/保存) |
| Provider 数 | 2 个Dify + MiniMax | **1 个**Dify |
| Fallback | 复杂的 Provider 切换 | **没有**Dify 失败就报错) |
| 星切换 | 动态 + dataset 映射 | **固定肖战** |
| 人设/风格/记忆 | 4 个 SystemInputs 参数 | **写死在 Dify Prompt** |
| 长期记忆 | MemoryStore + 5 轮触发 | **不做** |
| Provider 抽象 | `AIProvider` interface | **直接调 DifyClient** |
| Workflow 节点 | 5 个(含 Code + Moderation | **3 个**(开始/检索/LLM/结束) |
| 锁 | RedisLock | **不需要**(单实例部署,无并发问题) |
| 后端代码行数估算 | 1500-2000 | **300-500** |
---
## 三、Dify 端配置
### 3.1 准备知识库Dataset
1. 登录 Dify → "知识库" → "创建知识库"
2. 命名:`star-xz-kb`(固定一个)
3. 索引模式:`high_quality`
4. 导入肖战资料(作品、行程、近期事件等)
5. 等待向量化完成(每个文档显示 ✓)
### 3.2 创建 Workflow仅 3 节点)
1. 进入"工作室" → "创建空白应用" → 类型选 **Workflow**
2. 命名:`star-chat-workflow`
3. 配置"开始"节点的 Input 变量:
```yaml
inputs:
- name: query # 用户消息
type: text
```
> **注**`user_id`(哈希后的用户标识)由后端通过 Dify 协议**顶层 `user` 字段**传入(见 [§4.5 DifyClient](2026-06-29-ai-chat-dify-mvp-design.md#45-difyclient--mvp-唯一-ai-客户端)),不作为 workflow input。
>
> **与实际工作流对齐**[`docs/dify/角角.yml`](../../dify/角角.yml) v0.6.0 只声明了 `query` 一个 input。若后端仍把 `user_id` 塞进 `inputs`Dify 会静默丢弃,不影响功能(`user` 字段仍生效)。
4. 添加 **知识检索节点**
- Knowledge`star-xz-kb`(固定)
- Query`{{ query }}`
- TopK3
5. 添加 **LLM 节点**
```markdown
你叫"角角",是肖战的 AI 形象。
【用户问题】
{{ query }}
【知识库检索结果】
{{#knowledge_retrieval_node.result#}}
请用温柔、自然的语言回答,参考知识库内容,不要编造。
```
6. 添加"直接回复"或"结束"节点
**节点序列**
```
[开始] inputs:{query}
[知识检索] star-xz-kb固定
[LLM] 角角人设(固定)
[结束]
```
### 3.3 调试
1. 传 `inputs={query: "肖战最近在干嘛?", user_id: "aichat-xxxx"}`
2. 验证:返回基于知识库的回答
3. 发布 → 复制 API Key`app-xxx`
---
## 四、后端代码
### 4.1 改动总览
| 层级 | 改动 | 工作量 |
|------|------|--------|
| `model/ai_chat_models.go` | **新增**`AIConversation` / `AIMessage` GORM 模型 | 小 |
| `repository/conversation_repository.go` | **新建**ai_conversations / ai_messages CRUD | 中 |
| `service/chat_service.go` | **修改**:直接调 DifyClient不再有 ChatEngine 编排) | 中 |
| `provider/ai_chat_provider.go` | **修改**Dubbo 入口 + 调 ChatService | 小 |
| `main.go` | **修改**:加 ConversationRepository 装配 | 小 |
| `migrations/ai_conversations.sql` | **新建**2 张表 DDL | 小 |
| **前端** | **无改动** ✅ | 0 |
**总工作量**:约 **300-500 行核心代码**
### 4.2 model/ai_chat_models.go 新增
```go
package model
import "github.com/google/uuid"
type AIConversation struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
UserID int64 `gorm:"index;not null"`
StarID int64 `gorm:"index;not null"` // MVP 固定为 87
ProviderName string `gorm:"type:varchar(32);not null;default:'dify'"`
ExternalConvID string `gorm:"type:varchar(128);default:''"`
MessageCount int `gorm:"default:0"`
LastActiveAt int64 `gorm:"autoUpdateTime:milli"`
CreatedAt int64 `gorm:"autoCreateTime:milli"`
UpdatedAt int64 `gorm:"autoUpdateTime:milli"`
}
func (AIConversation) TableName() string { return "ai_conversations" }
type AIMessage struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ConversationID int64 `gorm:"index;not null"`
Role string `gorm:"type:varchar(16);not null"` // 'user' / 'assistant'
Content string `gorm:"type:text;not null"`
CreatedAt int64 `gorm:"autoCreateTime:milli"`
}
func (AIMessage) TableName() string { return "ai_messages" }
```
### 4.3 PostgreSQL DDL
```sql
CREATE TABLE IF NOT EXISTS ai_conversations (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
provider_name VARCHAR(32) NOT NULL DEFAULT 'dify',
external_conversation_id VARCHAR(128) DEFAULT '',
message_count INT DEFAULT 0,
last_active_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
);
CREATE INDEX idx_ai_conv_user_star ON ai_conversations(user_id, star_id);
CREATE TABLE IF NOT EXISTS ai_messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
role VARCHAR(16) NOT NULL,
content TEXT NOT NULL,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
);
CREATE INDEX idx_ai_messages_conversation ON ai_messages(conversation_id, created_at);
```
> MVP 阶段**不**加唯一约束user_id + star_id方便 Stage 2 加多星时再处理
> MVP 阶段**不**加 `is_archived` 等字段
### 4.4 ConversationRepository
```go
package repository
import (
"context"
"github.com/topfans/backend/services/aiChatService/model"
"gorm.io/gorm"
)
type ConversationRepository struct {
db *gorm.DB
}
func NewConversationRepository(db *gorm.DB) *ConversationRepository {
return &ConversationRepository{db: db}
}
func (r *ConversationRepository) GetOrCreate(ctx context.Context, userID, starID int64) (*model.AIConversation, error) {
var conv model.AIConversation
err := r.db.WithContext(ctx).Where("user_id = ? AND star_id = ?", userID, starID).First(&conv).Error
if err == gorm.ErrRecordNotFound {
conv = model.AIConversation{UserID: userID, StarID: starID, ProviderName: "dify"}
if err := r.db.WithContext(ctx).Create(&conv).Error; err != nil {
return nil, err
}
return &conv, nil
}
if err != nil {
return nil, err
}
return &conv, nil
}
func (r *ConversationRepository) AppendMessage(ctx context.Context, convID int64, role, content string) error {
msg := model.AIMessage{ConversationID: convID, Role: role, Content: content}
return r.db.WithContext(ctx).Create(&msg).Error
}
func (r *ConversationRepository) UpdateExternalConvID(ctx context.Context, convID int64, externalID string) error {
return r.db.WithContext(ctx).Model(&model.AIConversation{}).
Where("id = ?", convID).
Updates(map[string]interface{}{
"external_conversation_id": externalID,
"message_count": gorm.Expr("message_count + 1"),
"last_active_at": gorm.Expr("(EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT"),
}).Error
}
```
### 4.5 DifyClient★ MVP 唯一 AI 客户端)
```go
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/topfans/backend/pkg/logger"
"go.uber.org/zap"
)
type DifyClient struct {
apiBase string
workflowURL string
apiKey string
httpClient *http.Client
}
type DifyConfig struct {
APIBase string
WorkflowURL string
APIKey string
TimeoutSec int
}
func NewDifyClient(cfg DifyConfig) *DifyClient {
return &DifyClient{
apiBase: cfg.APIBase,
workflowURL: cfg.WorkflowURL,
apiKey: cfg.APIKey,
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
}
}
// StreamChat 流式调用 Dify Workflow
// 返回 (StreamReader, error)StreamReader 可逐 token 读取
func (c *DifyClient) StreamChat(ctx context.Context, query, userHashedID, convID string) (*DifyStreamReader, error) {
inputs := map[string]interface{}{
"query": query,
"user_id": userHashedID,
}
body := map[string]interface{}{
"inputs": inputs,
"response_mode": "streaming",
"conversation_id": convID, // 首次为空
"user": userHashedID,
}
jsonData, _ := json.Marshal(body)
httpReq, _ := http.NewRequestWithContext(ctx, "POST",
c.apiBase+c.workflowURL, bytes.NewReader(jsonData))
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("dify request: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("dify returned HTTP %d: %s", resp.StatusCode, string(body))
}
return &DifyStreamReader{reader: resp.Body, decoder: NewSSEDecoder(resp.Body), conversationID: ""}, nil
}
// DifyStreamReader 解析 Dify SSE 流
type DifyStreamReader struct {
reader io.ReadCloser
decoder *SSEDecoder
conversationID string
}
func (r *DifyStreamReader) Next() (content string, done bool, err error) {
for {
line, err := r.decoder.Next()
if err != nil {
if err == io.EOF { return "", true, nil }
return "", true, err
}
if !strings.HasPrefix(line, "data: ") { continue }
data := strings.TrimPrefix(line, "data: ")
var event struct {
Event string `json:"event"`
Answer string `json:"answer"`
ConversationID string `json:"conversation_id"`
}
if err := json.Unmarshal([]byte(data), &event); err != nil { continue }
if event.ConversationID != "" && r.conversationID == "" {
r.conversationID = event.ConversationID
}
switch event.Event {
case "message":
return event.Answer, false, nil
case "message_end":
return "", true, nil
case "error":
return "", true, fmt.Errorf("dify error event")
}
}
}
func (r *DifyStreamReader) GetConversationID() string { return r.conversationID }
func (r *DifyStreamReader) Close() error { return r.reader.Close() }
```
> ★ MVP 阶段**没有**滑动窗口审计、retry 循环、敏感词检测(现有 AuditService 已足够)
### 4.6 ChatService核心业务逻辑
```go
package service
type ChatService struct {
audit *AuditService
convRepo *repository.ConversationRepository
difyClient *DifyClient
userIDSalt string
}
func NewChatService(audit *AuditService, convRepo *repository.ConversationRepository, dify *DifyClient) *ChatService {
return &ChatService{audit: audit, convRepo: convRepo, difyClient: dify}
}
const (
DefaultStarID = int64(87) // 肖战
DefaultUserSalt = "topfans-default-salt"
)
func (s *ChatService) hashUserID(userID int64) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%d:%s", userID, s.userIDSalt)))
return "aichat-" + hex.EncodeToString(h[:8])
}
// SendMessage 核心流程4 步)
func (s *ChatService) SendMessage(ctx context.Context, userID int64, message string) (<-chan *StreamChunk, error) {
out := make(chan *StreamChunk, 16)
go func() {
defer close(out)
// 1. 前置审核
if !s.audit.AuditText(message) {
out <- &StreamChunk{Type: "message", Content: s.audit.DefaultSafeResponse(), IsEnd: false}
out <- &StreamChunk{Type: "message", IsEnd: true}
return
}
// 2. 获取/创建会话(★ V2 关键决策PostgreSQL 持久化)
conv, err := s.convRepo.GetOrCreate(ctx, userID, DefaultStarID)
if err != nil {
out <- &StreamChunk{Type: "error", Error: "会话创建失败"}
return
}
// 3. 调 Dify★ MVP 唯一 AI 源)
streamReader, err := s.difyClient.StreamChat(ctx, message, s.hashUserID(userID), conv.ExternalConvID)
if err != nil {
logger.Logger.Error("Dify call failed", zap.Error(err))
out <- &StreamChunk{Type: "error", Error: "服务暂不可用"}
return
}
defer streamReader.Close()
// 4. 流式返回 + 后置审核 + 保存
var fullResponse string
for {
content, done, err := streamReader.Next()
if err != nil {
out <- &StreamChunk{Type: "error", Error: "服务异常"}
return
}
if content != "" && !s.audit.AuditResponse(content) {
// 命中敏感词
out <- &StreamChunk{Type: "message", Content: s.audit.DefaultSafeResponse(), IsEnd: false}
out <- &StreamChunk{Type: "message", IsEnd: true}
s.convRepo.AppendMessage(ctx, conv.ID, "assistant", s.audit.DefaultSafeResponse())
return
}
fullResponse += content
out <- &StreamChunk{Type: "message", Content: content, IsEnd: done}
if done { break }
}
// 5. 更新 Dify conv_id首次
if newConvID := streamReader.GetConversationID(); newConvID != "" && newConvID != conv.ExternalConvID {
s.convRepo.UpdateExternalConvID(ctx, conv.ID, newConvID)
}
// 6. 保存消息
s.convRepo.AppendMessage(ctx, conv.ID, "user", message)
s.convRepo.AppendMessage(ctx, conv.ID, "assistant", fullResponse)
}()
return out, nil
}
// GetWelcomeMessage MVP 阶段固定返回"角角"欢迎语
func (s *ChatService) GetWelcomeMessage() string {
return "你好,我是角角,专注肖战的 AI 搭子。有什么想了解的?"
}
```
### 4.7 Provider 大幅简化
```go
package provider
type AIChatProvider struct {
chatService *service.ChatService
}
func (p *AIChatProvider) SendMessage(ctx context.Context, req *pb.ChatMessageRequest, stream pb.AIChatService_SendMessageServer) error {
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
if err != nil { return err }
chunks, err := p.chatService.SendMessage(ctx, userID, req.Message)
if err != nil { return err }
for chunk := range chunks {
if chunk.Type == "error" {
stream.Send(&pb.ChatMessageResponse{Content: chunk.Error, IsEnd: true})
} else {
stream.Send(&pb.ChatMessageResponse{Content: chunk.Content, IsEnd: chunk.IsEnd})
}
}
return nil
}
```
> **极简**~30 行。**完全没有 V2 里的 ChatEngine 编排、锁、Provider 抽象、Factory 等**
### 4.8 main.go 装配
```go
// MVP 装配:极简
convRepo := repository.NewConversationRepository(database.GetDB())
difyClient := service.NewDifyClient(service.DifyConfig{
APIBase: getEnv("DIFY_API_BASE", "https://api.dify.ai/v1"),
WorkflowURL: "/workflows/run",
APIKey: getEnv("DIFY_API_KEY", ""),
TimeoutSec: 60,
})
chatService := service.NewChatService(auditService, convRepo, difyClient)
aiChatProvider := provider.NewAIChatProvider(chatService)
```
---
## 五、消息协议(无改动)
WebSocket 协议与现有实现一致:
- Client → Server`{action: "send_message", session_id, message}`
- Server → Client`{type: "message", content, is_end}` 或 `{type: "error", error}`
**前端零改动**。
---
## 六、关键设计决策
| 决策 | 选择 | 理由 |
|------|------|------|
| Provider 抽象 | **不做** | MVP 只有 1 个 AI 源,抽象无价值 |
| MiniMax fallback | **不做** | Dify 失败就报错,避免增加复杂度 |
| 长期记忆提取 | **不做** | 业务假设未验证前不做 |
| Redis 缓存 | **保留** | 1h 缓存会话元数据,避免每次查 DB |
| PostgreSQL 持久化 | **保留(★ 关键)** | 跨设备/跨天续接(追星场景长生命周期) |
| 滑动窗口审计 | **不做** | 现有 AuditService 逐 token 检查已足够 |
| 锁 | **不做** | 单实例部署,并发问题不严重 |
| Dify 内容审核节点 | **不做** | 现有 AuditService 已拦截MVP 够用 |
| UserStyle/UserNickname 参数 | **不做** | 写死在 Dify Prompt 里 |
| 星切换 | **不做** | 固定 star_id=87肖战 |
---
## 七、配置清单
### 7.1 环境变量
| 变量 | 用途 | 必填 |
|------|------|------|
| `DIFY_API_KEY` | Dify Workflow API Key | 是 |
| `DIFY_API_BASE` | Dify API 地址 | 否(默认 https://api.dify.ai/v1 |
### 7.2 ai_chat_configs★ MVP 全部不要)
**MVP 阶段直接用环境变量,不写 ai_chat_configs 数据库**。
**V2 文档里 9 个 `dify.*` 配置项 MVP 全部不需要**enabler、api_base、workflow_url、star_dataset_mapping、api_key、timeout_sec、user_id_salt、retry_count、fallback_to_minimax——**全部 hardcode 或用环境变量**。
> Stage 2+ 才把这些移到数据库配置。
---
## 八、部署清单
> **不要分配实施时间**。按业务节奏推进。
### 8.1 数据库
- [ ] DBA 执行 `migrations/ai_conversations.sql`
- [ ] 验证表结构和索引
### 8.2 Dify 端
- [ ] Dify 管理员创建 `star-xz-kb` 知识库
- [ ] 导入肖战资料并等待向量化完成
- [ ] 创建 `star-chat-workflow`3 节点:开始/检索/LLM
- [ ] 配置 Prompt"你是角角,温柔回复,参考知识库..."
- [ ] 调试并发布
- [ ] 把 API Key 安全转给后端
### 8.3 后端代码
- [ ] 新建 `model/ai_chat_models.go` 的 AIConversation/AIMessage
- [ ] 新建 `repository/conversation_repository.go`
- [ ] 新建 `service/dify_client.go`
- [ ] 修改 `service/chat_service.go`**直接调 DifyClient不引入 ChatEngine**
- [ ] 简化 `provider/ai_chat_provider.go`
- [ ] 修改 `main.go` 装配
- [ ] 单元测试ConversationRepository CRUD
- [ ] 集成测试mock Dify server 跑完整 SendMessage
### 8.4 联调测试
- [ ] 内部账号测试:进 ai-dazi 页面发消息
- [ ] 验证:流式返回正常
- [ ] 验证ai_messages 表有 user + assistant 两条记录
- [ ] 验证:关掉重开会话能续接
- [ ] 验证:敏感词("裸聊"等)被拦截
- [ ] 验证Dify 故障时返回明确错误
---
## 九、验证清单
### 9.1 功能验证
- [ ] 发送"肖战最近在干嘛?"能返回基于知识库的回答
- [ ] 发送"你好"能返回通用问候
- [ ] 同用户第二次发消息能续接上下文Dify conversation_id
- [ ] ai_messages 表有 user + assistant 两条记录
- [ ] ai_conversations 表的 message_count 正确递增
### 9.2 安全验证
- [ ] 前置审核:用户发"裸聊"等敏感词被拦截
- [ ] 后置审核Dify 回复中含敏感词被拦截
- [ ] Dify API Key 不出现在日志
### 9.3 不验证Stage 2+ 再做)
- ~~多星切换~~MVP 不做)
- ~~Fallback~~MVP 不做)
- ~~长期记忆提取~~MVP 不做)
---
## 十、Stage 2+ 演进路径
MVP 跑通后,根据用户量和业务反馈,按以下顺序演进:
| Stage | 触发条件 | 关键改动 |
|-------|---------|---------|
| **Stage 2** | 用户量 > 1000 OR 加第 2 个星 | 1. 多星 Dataset 切换star_dataset_mapping<br>2. WebSocket 端 InitSession 欢迎语动态化<br>3. ai_conversations 加 UNIQUE(user_id, star_id) |
| **Stage 3** | Dify 偶发故障 OR SLA 要求 | 1. MiniMax fallback仅 message_count=0 时)<br>2. Dify retry 循环 |
| **Stage 4** | 用户量 > 10万 OR 接 2+ AI 平台 | 1. AIProvider 抽象<br>2. ProviderFactory 策略模式<br>3. CozeProvider / OpenAIProvider 实现 |
| **Stage 5** | 用户量 > 100万 OR 业务复杂 | 1. ChatEngine Pipeline 化<br>2. AIProfile 配置化A/B 测试、灰度)<br>3. 长期记忆提取 |
**关键原则**:每个 Stage 都是**业务驱动**,不是架构驱动。
### ★ Stage 2+ 演进时必踩的 3 个坑P0 修复笔记)
> ★ **这些是 V2 架构评审发现的真实 bug**MVP 阶段不修(流量小、问题不暴露),但 **Stage 2+ 流量上来后必现**
> ★ **必读**:实施 Stage 2 之前,**必须**先修这 3 个 P0 问题。
#### 坑 1并发请求分裂会话★ P0-1
**症状**:用户手机 + 平板同时发消息Dify 端产生两个会话AI 上下文错乱。
**根因**:两个并发请求都查到 `ExternalConvID=""`,都调 DifyDify 给两个不同的 `conversation_id`,后写入的覆盖先写入的。
**修复**
- 加 Redis 分布式锁 `conv_lock:{userId}:{starId}`TTL 30s
- 锁范围:`GetOrCreateConversation` → `UpdateExternalID`
- 锁未获取时 sleep 200ms 重试一次
#### 坑 2审计拦截后不保存对话★ P0-2
**症状**用户每次触发敏感词拦截后AI 都不记得之前说过什么,行为诡异。
**根因**:审计分支直接 return没保存"user 原句 + 安全回复"。
**修复**:审计分支也调 `AppendConversationMessages` 保存对话。
#### 坑 3组合敏感词漏检★ P0-3
**症状**Dify 返回"色"+"情"分两个 token单独都不违规组合违规。
**根因**V1 AuditService 逐 token 检查(`strings.Contains(token, word)`),单 token 视角。
**修复**Dify 流式接收时维护 sliding window buffer20 字符),每个 token 检查 buffer。
### ★ Stage 4 抽象时必踩的 3 个坑(架构评审笔记)
> ★ 这些是 V2 架构评审发现的 **设计层面**问题Stage 4 做 AIProvider 抽象时必踩。
#### 坑 4Provider God Class评审 #1
**症状**DifyProvider 写了 2000+ 行什么都管HTTP / Stream / Cache / Conversation / Retry / Hash / Audit
**修复**:拆为 4 个组件:
- `WorkflowClient`HTTP + SSE 解析 + Retry
- `HistoryClient`:拉历史消息
- `DatasetResolver`star_id → dataset_id 映射
- `ConversationStore`:缓存 + 持久化
**原则**Provider 只做协调,不做任何具体工作。
#### 坑 5Provider 直接依赖 Redis/Repository评审 #5
**症状**Provider 改存储Redis → Memcached所有 Provider 都要改。
**修复**:引入 `ConversationStore` 抽象Provider 只依赖接口。底层是 `CachedConversationStore`PostgreSQL + Redis 缓存)。
#### 坑 6Workflow 与 Backend 重复维护 dataset 映射(评审 #3
**症状**Backend 改 `star_dataset_mapping` 忘改 Workflow → 用户问"肖战"答"王一博"的资料。
**修复**
- **单一数据源**:映射只在 Backend `ai_chat_configs.dify.star_dataset_mapping` 维护
- Workflow 输入直接接 `dataset_id`Backend 传过来的)
- Workflow 内部**不**维护任何 `star_id → dataset_id` 映射(**没有 Code 节点**
### ★ 演进时不要做
- ❌ **不要预先做 P0 修复**MVP 阶段流量小race / 审计保存 / 组合敏感词都暴露不出来
- ❌ **不要预先做 Provider 抽象**MVP 只有 1 个 AI 源(写死就行)
- ❌ **不要预先做 Pipeline**SendMessage 函数 < 200 行不需要 Pipeline
- ❌ **不要预先做 AIProfile**1 个星 1 种 AI 源根本不需要 A/B
---
## 十一、与 V2 文档的关系
**V2 文档已删除**2026-06-29 决定)。
原因V2 是"100 明星 + 多 AI 平台"的完整架构设计,**MVP 不需要 80% 的内容**。V2 关键内容已迁移到本 MVP 文档的 [§10 演进路径](#十stage-2-演进路径),包含 3 个 P0 修复笔记 + 3 个 Stage 4 抽象笔记。
实施时按 MVP 推进,跑通后再按 §10 Stage 2+ 演进。
---
## 十二、关键文件清单
### 12.1 新建文件
```
backend/services/aiChatService/
├── model/
│ └── ai_chat_models.go (新增 AIConversation / AIMessage struct)
├── repository/
│ └── conversation_repository.go (新建3 个方法)
├── service/
│ ├── dify_client.go (新建,唯一 AI 客户端)
│ └── chat_service.go (修改4 步流程)
migrations/
└── ai_conversations.sql (新建2 张表 DDL)
```
### 12.2 修改文件
```
backend/services/aiChatService/
├── provider/
│ └── ai_chat_provider.go (大幅简化)
└── main.go (加 convRepo + difyClient 装配)
```
### 12.3 不动文件
- 前端(所有 .vue / .js
- GatewayWebSocket Hub
- AuditService现有代码MVP 沿用)
- JWT 鉴权(现有代码)
---
## 总结
**MVP 阶段的核心是验证业务假设,不是搭建完美架构**。
### MVP 范围
- ✅ 用户能跟"角角"聊天
- ✅ AI 能基于肖战知识库回答
- ✅ 对话跨天续接PostgreSQL 持久化)
- ✅ 敏感词拦截AuditService 沿用)
### 不做(业务驱动)
- ❌ Provider 抽象(只有 1 个 AI 源)
- ❌ FallbackDify 失败就报错)
- ❌ 多星(只有 1 个星)
- ❌ 长期记忆提取(每轮直接入 ai_messages
- ❌ Persona 自定义(写死在 Dify Prompt
- ❌ Dify 内容审核节点AuditService 够用)
- ❌ 9 个 dify.* 数据库配置(环境变量够用)
### 业务驱动原则
每个 Stage 都是**业务驱动**,不是架构驱动:
1. 业务没到的复杂度 → **不预先做**
2. 架构是演进的,**不是一次性完美设计**
3. V2 完整文档作为**长期演进路线图****不删除**
4. 跑通 MVP 后,按 §十 Stage 2-5 渐进改进
**这些就够了**。其他都是"未来 100 明星 + 多 AI 平台"的事。

View File

@ -0,0 +1,678 @@
# 光栅卡 WebGL 引擎实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 用 WebGL 实现光栅卡的条纹级像素交织,替代当前 CSS DOM opacity 叠化方案
**Architecture:** 新增 `LenticularWebGLEngine`WebGL 引擎类)和片段着色器(条纹交织 + 圆角暗角),保留 `LenticularEngine` 的现有权重算法并在 `computeRenderState()` 末尾汇出 `engineUniforms``useLenticularPreview` 增加 `registerOnTick` 回调实现唯一 rAF 驱动,`LenticularCard.vue` 改为 WebGL / CSS DOM 双路径渲染
**Tech Stack:** WebGL1 (ES 100), uniapp APP-PLUS, Vue 3 composable
## Global Constraints
- 仅适配 APP-PLUS无 H5/小程序降级
- 2 层图(背景 + 主体),不支持 3+
- 无全息效果、无噪声、无色散、无安全区域
- `LenticularEngine` 原有输出 `renderState` + `layerTransforms` 不破坏
- 文件组织与 `utils/laser-card/` 对仗,放 `utils/lenticular-card/`
- WebGL 不可用时自动降级到现有 CSS DOM 路径
- 陀螺仪代码 `useLenticularStudioTilt.js` 不修改
---
## 文件结构
### 新增
| 文件 | 职责 |
|---|---|
| `frontend/utils/lenticular-card/lenticular-webgl-shaders.js` | VERT_SRC + FRAG_SRC~80 行 GLSL |
| `frontend/utils/lenticular-card/lenticular-webgl-engine.js` | `LenticularWebGLEngine` 类(~200 行) |
### 修改
| 文件 | 改动 |
|---|---|
| `frontend/utils/lenticular-engine.js` → 移入 `utils/lenticular-card/` | 文件迁移 + `computeRenderState()` 末尾汇出 `engineUniforms` |
| `frontend/composables/useLenticularPreview.js` | 追加 `engineUniforms` 返回值 + `registerOnTick` |
| `frontend/components/lenticular/LenticularCard.vue` | 双路径渲染 + 引擎生命周期 |
### 不改
`useLenticularStudioTilt.js`、`HolographicCard.vue`、`HolographicEngine`、全部页面层(`lenticular-create/result/thinking.vue`
---
## Task 1: 建立 `utils/lenticular-card/` 目录并迁移 `lenticular-engine.js`
**Files:**
- Create: `frontend/utils/lenticular-card/`
- Move: `frontend/utils/lenticular-engine.js``frontend/utils/lenticular-card/lenticular-engine.js`
**Interfaces:**
- Consumes: 无(纯文件迁移)
- Produces: `lenticular-engine.js``utils/lenticular-card/` 目录下可被 `import` 引用
- [ ] **Step 1: 创建目录**
```bash
mkdir -p frontend/utils/lenticular-card
```
- [ ] **Step 2: 复制文件到新位置**
```bash
cp frontend/utils/lenticular-engine.js frontend/utils/lenticular-card/lenticular-engine.js
```
- [ ] **Step 3: 确认所有引用旧路径的地方**
搜索 `@/utils/lenticular-engine.js` 找到所有引用:
```bash
grep -rn "lenticular-engine" frontend/ --include="*.js" --include="*.vue"
```
预期至少命中:
- `frontend/composables/useLenticularPreview.js`
- `frontend/components/lenticular/LenticularCard.vue`
- 可能的测试文件
记录所有路径,后续 task 逐个更新。
- [ ] **Step 4: 删除旧文件**
```bash
rm frontend/utils/lenticular-engine.js
```
---
## Task 2: 编写 lenticular-webgl-shaders.js
**Files:**
- Create: `frontend/utils/lenticular-card/lenticular-webgl-shaders.js`
**Interfaces:**
- Consumes: 无
- Produces: `export const VERT_SRC``export const FRAG_SRC`,供 `LenticularWebGLEngine` 使用
- [ ] **Step 1: 实现 VERT_SRC + FRAG_SRC**
```js
/**
* 光栅卡 WebGL 着色器
* 管线UV 偏移 → 条纹交织 → 输出修饰
* 无全息效果
*/
export const VERT_SRC = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
v_texCoord = a_texCoord;
gl_Position = vec4(a_position * 2.0 - 1.0, 0.0, 1.0);
}
`
export const FRAG_SRC = `
precision highp float;
varying vec2 v_texCoord;
uniform sampler2D u_textureA;
uniform sampler2D u_textureB;
uniform float u_parallax;
uniform float u_phase;
uniform float u_density;
uniform float u_cornerRadius;
uniform vec2 u_resolution;
uniform float u_dpr;
// ---- 圆角 SDF ----
float roundedRectSDF(vec2 p, vec2 halfSize, float r) {
vec2 q = abs(p) - halfSize + r;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}
void main() {
// ① UV 偏移
vec2 uv = v_texCoord;
uv.x += u_parallax * 0.03;
float scale = 1.0 + abs(u_parallax) * 0.015;
uv = (uv - 0.5) * scale + 0.5;
// ② 条纹交织 —— 每个像素只来自一张图
float stripe = fract(uv.x * u_density + u_phase);
float mask = smoothstep(0.45, 0.55, stripe);
vec4 baseColor = texture2D(u_textureA, uv);
vec4 frontColor = texture2D(u_textureB, uv);
vec4 finalColor = mix(baseColor, frontColor, mask);
// ③ 输出修饰
vec2 halfRes = u_resolution * 0.5;
vec2 pn = (uv - 0.5) * u_resolution;
float cornerRadPx = u_cornerRadius * u_dpr;
float sdf = roundedRectSDF(pn, halfRes - cornerRadPx, cornerRadPx);
if (sdf > 1.5) discard;
// 暗角
vec2 pnNorm = pn / max(halfRes.x, halfRes.y);
float vignette = 1.0 - pow(clamp(length(pnNorm) * 1.1, 0.0, 1.0), 2.8) * 0.35;
finalColor.rgb *= vignette;
// 边缘抗锯齿
float cornerMask = 1.0 - smoothstep(-1.5, 1.5, sdf);
float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf);
float alpha = cornerMask * edgeAA;
finalColor = clamp(finalColor, 0.0, 1.0);
gl_FragColor = vec4(finalColor.rgb, alpha);
}
`
```
---
## Task 3: 编写 lenticular-webgl-engine.js
**Files:**
- Create: `frontend/utils/lenticular-card/lenticular-webgl-engine.js`
**Interfaces:**
- Consumes: `VERT_SRC`, `FRAG_SRC` from `./lenticular-webgl-shaders.js`; `loadTextureImage` from `@/utils/laser-card/laserPreviewWebgl.js`
- Produces: `class LenticularWebGLEngine` 暴露 `init(textureA, textureB)`, `resize(w, h)`, `setUniforms({parallax, phase, density})`, `draw()`, `uploadTexture(index, image)`, `destroy()`
- [ ] **Step 1: 实现 LenticularWebGLEngine 类**
```js
/**
* 光栅卡 WebGL 渲染引擎
* 单 DrawCall8 uniforms3 阶段管线
*/
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
import { VERT_SRC, FRAG_SRC } from './lenticular-webgl-shaders.js'
export class LenticularWebGLEngine {
constructor(canvas) {
this.canvas = canvas
this.gl = null
this.program = null
this.uniformLocs = {}
this.textures = [null, null]
this._dpr = 1
this._uniforms = { parallax: 0, phase: 0, density: 0.16 }
this._initialized = false
this._destroyed = false
}
init(textureSrcA, textureSrcB) {
if (this._initialized || this._destroyed) return false
const gl = this.canvas.getContext('webgl', {
alpha: true, antialias: true, premultipliedAlpha: false,
powerPreference: 'high-performance',
})
if (!gl) return false
this.gl = gl
this._dpr = Math.min(window.devicePixelRatio || 1, 2)
this._syncSize()
// 编译 shader
const vs = this._compileShader(gl.VERTEX_SHADER, VERT_SRC)
const fs = this._compileShader(gl.FRAGMENT_SHADER, FRAG_SRC)
if (!vs || !fs) return false
this.program = gl.createProgram()
gl.attachShader(this.program, vs)
gl.attachShader(this.program, fs)
gl.linkProgram(this.program)
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) return false
// 缓存 uniform 位置
const names = ['u_textureA', 'u_textureB', 'u_parallax', 'u_phase', 'u_density',
'u_cornerRadius', 'u_resolution', 'u_dpr']
for (const name of names) {
this.uniformLocs[name] = gl.getUniformLocation(this.program, name)
}
// 全屏四边形
const verts = new Float32Array([0,0, 1,0, 0,1, 1,0, 0,1, 1,1])
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW)
const posLoc = gl.getAttribLocation(this.program, 'a_position')
const tcLoc = gl.getAttribLocation(this.program, 'a_texCoord')
gl.enableVertexAttribArray(posLoc)
gl.enableVertexAttribArray(tcLoc)
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0)
gl.vertexAttribPointer(tcLoc, 2, gl.FLOAT, false, 16, 8)
// 纹理
this.textures[0] = gl.createTexture()
this.textures[1] = gl.createTexture()
gl.useProgram(this.program)
gl.uniform1i(this.uniformLocs.u_textureA, 0)
gl.uniform1i(this.uniformLocs.u_textureB, 1)
// 加载纹理
if (textureSrcA) this._loadTexture(0, textureSrcA)
if (textureSrcB) this._loadTexture(1, textureSrcB)
this._initialized = true
return true
}
resize(cssW, cssH) {
const w = Math.round(cssW * this._dpr)
const h = Math.round(cssH * this._dpr)
if (this.canvas.width === w && this.canvas.height === h) return
this.canvas.width = w
this.canvas.height = h
this.gl && this.gl.viewport(0, 0, w, h)
}
_syncSize() {
const rect = this.canvas.getBoundingClientRect()
if (rect.width && rect.height) this.resize(rect.width, rect.height)
}
setUniforms(u) {
Object.assign(this._uniforms, u)
}
draw() {
const gl = this.gl
if (!gl || !this._initialized) return
gl.useProgram(this.program)
gl.uniform1f(this.uniformLocs.u_parallax, this._uniforms.parallax)
gl.uniform1f(this.uniformLocs.u_phase, this._uniforms.phase)
gl.uniform1f(this.uniformLocs.u_density, this._uniforms.density)
gl.uniform1f(this.uniformLocs.u_cornerRadius, this._uniforms.cornerRadius || 24)
gl.uniform2f(this.uniformLocs.u_resolution, this.canvas.width, this.canvas.height)
gl.uniform1f(this.uniformLocs.u_dpr, this._dpr)
gl.drawArrays(gl.TRIANGLES, 0, 6)
}
uploadTexture(index, image) {
if (index < 0 || index > 1 || !this.gl) return
this._loadTexture(index, image)
}
async _loadTexture(index, src) {
const gl = this.gl
const img = typeof src === 'string'
? await loadTextureImage(src)
: src
if (!img) return
gl.activeTexture(gl.TEXTURE0 + index)
gl.bindTexture(gl.TEXTURE_2D, this.textures[index])
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.generateMipmap(gl.TEXTURE_2D)
}
_compileShader(type, src) {
const gl = this.gl
const s = gl.createShader(type)
gl.shaderSource(s, src)
gl.compileShader(s)
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('[LenticularWebGLEngine] shader compile error:', gl.getShaderInfoLog(s))
return null
}
return s
}
destroy() {
const gl = this.gl
if (gl) {
if (this.program) gl.deleteProgram(this.program)
for (const t of this.textures) { if (t) gl.deleteTexture(t) }
}
this._destroyed = true
this._initialized = false
}
}
```
---
## Task 4: lenticular-engine.js 追加 engineUniforms 汇出
**Files:**
- Modify: `frontend/utils/lenticular-card/lenticular-engine.js``computeRenderState()` 末尾
**Interfaces:**
- Consumes: 已有 `this.renderState`、`this.physics`、`this.displayGamma`
- Produces: `computeRenderState()` 返回值新增字段 `engineUniforms: { parallax, phase, density }`
- [ ] **Step 1: computeRenderState() 末尾追加汇出代码**
找到 `lenticular-engine.js``computeRenderState()``return this.renderState` 行,在其之前插入 engineUniforms 计算。注意 `this.renderState.stripePhaseShift` 已经由原有逻辑写入。
```js
// lenticular-engine.js — computeRenderState() 末尾,在 return 之前追加
// ===== WebGL engineUniforms 汇出(不破坏原有输出)=====
const parallax = this.displayGamma * (this.physics.tiltSensitivity / 100) * (this.physics.parallaxDepth || 0) * 0.42
const sensitivity = this.physics.tiltSensitivity / 100
const stripePhaseShift = this.renderState.stripePhaseShift != null
? this.renderState.stripePhaseShift
: this.displayGamma * (0.38 + 0.52 * sensitivity)
const phase = (stripePhaseShift + 1) / 2 // -1~1 → 0~1
const pitchPx = this.renderState.lenticularPitchPx || 16
const density = 50 / pitchPx
this.renderState.engineUniforms = {
parallax: clamp(parallax, -1, 1),
phase: clamp(phase, 0, 1),
density: clamp(density, 0.08, 0.3),
}
return this.renderState
```
注意:函数顶部已有 `clamp`,无需重复定义。
---
## Task 5: useLenticularPreview.js 追加 engineUniforms + registerOnTick
**Files:**
- Modify: `frontend/composables/useLenticularPreview.js`
- Modify: 更新 Task 1 中旧路径引用为 `@/utils/lenticular-card/lenticular-engine`
**Interfaces:**
- Consumes: `LenticularEngine``computeRenderState()` 返回的 `engineUniforms`
- Produces: 新增返回值 `engineUniforms`ref、`registerOnTick(fn)` 函数
- [ ] **Step 1: 更新 import 路径**
```js
// 旧
import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-engine.js'
// 新
import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-card/lenticular-engine.js'
```
- [ ] **Step 2: 新增 registerOnTick 机制**
`useLenticularPreview` 函数体内,`let rafId = null` 之后新增:
```js
let onTickCallback = null
export function registerOnTick(fn) {
onTickCallback = typeof fn === 'function' ? fn : null
}
```
- [ ] **Step 3: tick() 中调用 onTickCallback**
`tick()` 函数从:
```js
function tick() {
try {
const ls = getLayersArray()
const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
applyLayerTransformsFromRenderState(ls, renderState)
} catch (e) {
console.error('[useLenticularPreview] tick failed', e)
}
rafId = nextFrame(tick)
}
```
改为:
```js
function tick() {
try {
const ls = getLayersArray()
const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
applyLayerTransformsFromRenderState(ls, renderState)
// WebGL onTick
if (onTickCallback && renderState.engineUniforms) {
onTickCallback(renderState.engineUniforms)
}
} catch (e) {
console.error('[useLenticularPreview] tick failed', e)
}
rafId = nextFrame(tick)
}
```
- [ ] **Step 4: 追加到返回值**
```js
// 现有 return 末尾追加
return {
// ... 原有返回值
registerOnTick,
}
```
---
## Task 6: 重构 LenticularCard.vue — 双路径渲染
**Files:**
- Modify: `frontend/components/lenticular/LenticularCard.vue`
- Modify: 更新 import 路径为 `@/utils/lenticular-card/lenticular-engine`
**Interfaces:**
- Consumes: `useLenticularPreview` 返回的 `layerTransforms` + `registerOnTick`
- Consumes: `LenticularWebGLEngine` from `@/utils/lentricular-card/lenticular-webgl-engine`
- Consumes: `loadTextureImage` from `@/utils/laser-card/laserPreviewWebgl.js`
- [ ] **Step 1: 更新 script setup 头部**
```js
<script setup>
import { computed, getCurrentInstance, onMounted, ref, watch, nextTick } from 'vue'
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
import { LenticularWebGLEngine } from '@/utils/lenticular-card/lenticular-webgl-engine.js'
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
const props = defineProps({
layers: { type: Array, required: true },
transforms: { type: Object, default: () => ({}) },
gyroSource: { type: String, default: 'simulation' },
tiltHintText: { type: String, default: '倾斜手机预览' },
approximatePreview: { type: Boolean, default: true },
skipBuiltInTouch: { type: Boolean, default: false },
// WebGL 专属
cornerRadius: { type: Number, default: 24 },
webglPreferred: { type: Boolean, default: true },
shimmerMidOpacity: { type: Number, default: 0.1 },
})
const emit = defineEmits(['simulate', 'ready', 'error'])
```
- [ ] **Step 2: 新增 WebGL 状态 + 引擎生命周期**
```js
const pageProxy = getCurrentInstance()?.proxy
const showHint = ref(true)
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
const cardRect = ref(null)
// ---- WebGL 状态 ----
const webglCanvas = ref(null)
const useWebgl = ref(false)
let webglEngine = null
let resizeObserver = null
```
- [ ] **Step 3: 接入 composable + 注册 onTick**
```js
const layersRef = computed(() => props.layers)
const { physics, layerTransforms, stripeRender, gyro, simulate, relax, snapSimulatedTilt,
startRenderLoop, stopRenderLoop, registerOnTick } = useLenticularPreview(layersRef)
```
- [ ] **Step 4: 初始化 WebGLonMounted 中)**
```js
async function initWebGL() {
if (!props.webglPreferred) return false
const canvas = webglCanvas.value
if (!canvas) return false
const engine = new LenticularWebGLEngine(canvas)
// 获取两张图的 src
const srcA = props.layers[0]?.src || ''
const srcB = props.layers[1]?.src || ''
const ok = engine.init(srcA, srcB)
if (!ok) {
engine.destroy()
return false
}
webglEngine = engine
// 监听尺寸变化
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
for (const e of entries) {
const { width, height } = e.contentRect
if (engine && width > 0 && height > 0) engine.resize(width, height)
}
})
ro.observe(canvas.parentElement || canvas)
}
// 注册 onTick
registerOnTick((uniforms) => {
if (webglEngine) {
webglEngine.setUniforms({
...uniforms,
cornerRadius: props.cornerRadius,
})
webglEngine.draw()
}
})
useWebgl.value = true
emit('ready')
return true
}
```
- [ ] **Step 5: onMounted 决策 WebGL / CSS**
```js
onMounted(async () => {
const webglOk = await initWebGL()
if (!webglOk) {
// 降级到 CSS DOM
setTimeout(() => { void refreshRect() }, 0)
emit('error', new Error('WebGL init failed, fallback to CSS'))
}
startRenderLoop()
})
onUnmounted(() => {
stopRenderLoop()
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
if (webglEngine) {
webglEngine.destroy()
webglEngine = null
}
})
```
- [ ] **Step 6: 更新 template 为双路径**
```vue
<template>
<view class="card-container">
<!-- WebGL 路径 -->
<canvas
v-if="useWebgl"
ref="webglCanvas"
class="card-canvas"
:style="{ width: '100%', height: '100%' }"
@touchstart.stop="onFrameTouchStart"
@touchmove.stop.prevent="onFrameTouchMove"
@touchend.stop="onFrameTouchEnd"
@touchcancel.stop="onFrameTouchEnd"
/>
<!-- CSS 降级路径 -->
<template v-else>
<view
:id="cardId"
class="card-frame"
@touchstart.stop="onFrameTouchStart"
@touchmove.stop.prevent="onFrameTouchMove"
@touchend.stop="onFrameTouchEnd"
@touchcancel.stop="onFrameTouchEnd"
>
<view class="card-body" :style="cardRotateStyle">
<view
v-for="layer in layers"
:key="layer.id"
class="card-layer"
:style="getLayerStyle(layer)"
>
<image
v-if="layer.src"
class="card-layer-img"
:src="layer.src"
mode="aspectFill"
@error="onImageError"
@load="onImageLoad"
/>
</view>
<view class="lenticular-shimmer" :style="shimmerStyle" />
<view class="lenticular-tint" />
<view class="glass-rim" />
<view class="vignette" />
</view>
</view>
</template>
</view>
</template>
```
- [ ] **Step 7: 添加 CSS**
```css
<style scoped>
.card-canvas {
display: block;
width: 100%;
height: 100%;
}
/* 保留原有 CSS DOM 路径的全部样式 */
.card-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; perspective: 1000px; position: relative; }
.card-frame { position: relative; height: 100%; width: 100%; overflow: visible; }
.card-body { position: absolute; inset: 0; transform-style: preserve-3d; box-shadow: 0 20px 50px rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.18); background-color: #060e20; will-change: transform; }
.card-layer { position: absolute; width: 100%; height: 100%; will-change: transform, opacity; background-size: cover; background-position: center; }
.card-layer-img { width: 100%; height: 100%; }
.lenticular-shimmer { position: absolute; inset: 0; pointer-events: none; opacity: 0.85; }
.lenticular-tint { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(135deg, rgba(221,183,255,0.1) 0%, transparent 35%, transparent 65%, rgba(76,215,246,0.1) 100%); opacity: 0.6; }
.glass-rim { position: absolute; inset: 0; border-radius: 24px; border-top: 1px solid rgba(221,183,255,0.42); box-shadow: inset 0 0 40px rgba(255,255,255,0.05); pointer-events: none; }
.vignette { position: absolute; inset: 0; background: linear-gradient(to top, rgba(6,14,32,0.82), transparent 50%); pointer-events: none; }
</style>
```
---
## 验收标准
| # | 验证项 | 方法 |
|---|---|---|
| 1 | WebGL 条纹交织 | 创建页打开,两张图倾斜时条纹交替,无叠化模糊 |
| 2 | CSS DOM 降级 | 设置 `webglPreferred=false`,页面与改动前一致 |
| 3 | 陀螺仪驱动 | 结果页用真机倾斜,条纹随角度平滑移动,无抖动 |
| 4 | 生命周期 | 页面进出各 3 次,无 WebGL context lost 报错 |
| 5 | Android 低端机 | 测试机输出 `fps > 50`,倾斜响应 < 50ms |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,472 @@
# 光栅卡 WebGL 引擎整合设计
> **创建日期:** 2026-06-26
> **项目:** TopFans 星卡 · 光栅卡渲染升级
> **状态:** 设计审核中
> **版本:** v1.0
---
## 一、背景与目标
### 1.1 现状
光栅卡目前采用**CSS DOM 渲染路径**`LenticularEngine`(纯 JS计算每层权重/偏移,`LenticularCard.vue` 通过 `v-for` 渲染 N 个 `<view>` + `<image>` 标签,用 `translate3d` + `opacity` 模拟柱镜光栅的视角切换效果。
镭射卡已有独立的 `HolographicCard.vue` + `HolographicEngine`WebGL但**该组件是做单图全息光效的,不解决多图条纹交织问题**。
### 1.2 问题
| 问题 | 说明 |
|---|---|
| 无真正的条纹级像素交织 | 当前是叠化opacity crossfade不是物理柱镜的"逐像素切换" |
| N>2 时性能线性衰减 | 每多一层就多一个 `<image>` 合成层 |
| 两套引擎职责混淆 | HolographicEngine 被误认为"光栅的 WebGL 方案",实际它只处理单图全息 |
### 1.3 目标
- 用 WebGL 实现真正的条纹级像素交织
- 保留现有 `LenticularEngine` 的成熟权重算法和陀螺仪稳定逻辑
- 不破坏现有 CSS DOM 降级路径
- 代码组织与镭射卡 `utils/laser-card/` 对仗
---
## 二、视觉目标Before / After
### 现在CSS opacity 叠化
倾斜手机时,用户看到的是两张图以不同透明度叠加:
```
倾斜 0° 倾斜 -15° 倾斜 +15°
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ A 图 100% │ │ A 图 60% │ │ A 图 0% │
│ B 图 0% │ → │ B 图 40% │ → │ B 图 100% │
│ │ │ (半透叠化) │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
效果A 渐隐、B 渐显,全程两张图同时存在,类似"交叉淡入淡出"
问题:没有"一张图上、一张图下"的切换感
```
### 改后WebGL 条纹级像素交织
每个像素只来自一张图,相邻像素交替采样 A/B
```
倾斜 0° 倾斜 -15° 倾斜 +15°
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │
│ A│B│A│B│A│B │ → │ A│A│B│B│A│A │ → │ B│A│B│A│B│A │
│ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │
└──────────────┘ └──────────────┘ └──────────────┘
每个格子 = 1 个像素
效果:倾斜时 A 图区域缩小、B 图区域扩大,条纹边界非此即彼
视觉:类似真实物理柱镜片的"啪一下亮出来"
```
### 一句话总结
> **从"两张图互相半透叠化"变为"逐像素条纹交替",视觉上从"数字感"变为"物理柱镜卡片的质感"。**
---
## 三、关键决策
| 决策 | 选项 | 结论 |
|---|---|---|
| 图层数 | A) 2 层 / B) 3 层 / C) 动态 | **A) 2 层**(背景 + 主体) |
| WebGL 降级 | A) CSS fallback / B) 静态图 / C) 不做 | **A) CSS DOM fallback** |
| 技术路线 | A) 新引擎 / B) 扩展现有 / C) PixiJS | **A) 新 LenticularWebGLEngine** |
| Composable 职责 | 仅数据逻辑,不碰 WebGL 生命周期 | 与组件现有模式一致 |
| 目录层级 | 与镭射卡 `utils/laser-card/` 对仗 | `utils/lenticular-card/` |
---
## 四、整体架构
```
┌───────────────────────┐
│ useLenticularStudio- │ ← 不改
│ Tilt.js (陀螺仪采集) │
└──────────┬────────────┘
│ gamma/beta
┌──────────▼────────────┐
│ LenticularEngine │ ← 追加 engineUniforms 汇出
│ (视角→权重/偏移动画) │ 不破坏原输出
└──────────┬────────────┘
│ parallax, phase, density
┌──────────▼────────────┐
│ useLenticularPreview │ ← 追加 engineUniforms 返回值
│ (composable) │
└──────────┬────────────┘
┌────────────────────┴────────────────────┐
│ WebGL 可用 │ CSS 不可用
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ LenticularWebGLEngine│ │ (现有 CSS DOM 路径) │
│ (新增,组件内部管理) │ │ layerTransforms → │
│ 8 个 uniforms → │ │ translate3d+opacity │
│ 1 draw call │ │ │
└──────────┬───────────┘ └──────────────────────┘
Fragment Shader 管线:
① UV parallax 偏移
② stripe 条纹交织 (mix textureA/B)
③ 输出修饰 (圆角/暗角/edgeAA)
```
### 4.1 模块职责
| 模块 | 职责 | 改否 |
|---|---|---|
| `useLenticularStudioTilt.js` | 陀螺仪采集 + 跳变拒绝 + EMA 平滑 | 不改 |
| `LenticularEngine` | 视角→权重映射stripSharesEMA 平滑 | 追加 `engineUniforms` 汇出,不破坏原输出 |
`engineUniforms` 格式:
```js
{
parallax: float, // -1~1视差偏移量
phase: float, // 0~1条纹相位由 displayGamma 映射)
density: float, // ≈0.08~0.3,条纹密度(由 lenticularPitchPx 算出)
}
```
| `useLenticularPreview` | 管理 rAF 循环,暴露 layerTransforms + engineUniforms | 追加 engineUniforms 返回值 |
| `LenticularWebGLEngine` | **新增**。WebGL init/compile/resize/rAF/destroy | — |
| `LenticularCard.vue` | 渲染入口,自选 WebGL/CSS 路径 | 重写 template保留 fallback |
---
## 五、完整调用链路(从陀螺仪到屏幕像素)
以下是一个倾斜动作从上到下经过的全部环节:
```
用户手指在卡面上拖动 / 手机倾斜
[硬件层]
deviceorientation 事件 (陀螺仪) 或 touchstart/touchmove (拖动)
│ 原始角度/坐标
[useLenticularStudioTilt.js] ← 不改
跳变拒绝abs(raw - prev) > 15° → clamp(prev ± 15°)
EMA 平滑 (快通道 α=0.25, 慢通道 α=0.08)
delta 死区abs < 0.5° 0
│ 输出: { gamma: -1~1, beta: 0 }
[LenticularEngine.updateDisplayStable()]
sensorDeadzoneStrength → softAttenuateNearZero
│ 输出: displayGamma: -1~1
[LenticularEngine.computeRenderState()]
入参: displayGamma, physics (sensitivity, smoothness, depth, pitch 等)
├─ 计算 stripSharesN=2 时各 0.5N=3 时分 background/foreground/mid
├─ 计算 rawWeights视角位置 u 落在各层覆盖区的距离)
├─ smoothstep 平滑 → smoothedW
├─ 找到 dominant 层
├─ prevLayerGhost + nonDominantResidual 保底
├─ 按 parallaxFactor 计算各层 offsetXCSS 降级用)
└─ 汇出 engineUniforms新增部分不破坏以上原有输出:
{
parallax: displayGamma × sensitivity × depth × 0.42, // -1~1
phase: (displayGamma × 0.38 + 0.5) % 1, // 0~1
density: 50 / lenticularPitchPx, // ≈3~6
}
[useLenticularPreview.tick()] ← rAF 循环(唯一驱动源)
│ 每帧 16ms 执行一次
├─ [CSS 降级路径]: applyLayerTransformsFromRenderState()
│ → 更新 layerTransforms.value → Vue 响应式 → DOM re-render
└─ [WebGL 路径]: 调用 registerOnTick 回调
[LenticularWebGLEngine.setUniforms(uniforms)]
│ 设置 8 个 uniform:
│ u_parallax, u_phase, u_density ← 来自 engineUniforms
│ u_textureA, u_textureB ← 纹理绑定
│ u_cornerRadius, u_resolution, u_dpr ← 来自组件 props + canvas
[LenticularWebGLEngine.draw()]
├─ gl.useProgram(program) ← 编译好的 shader
├─ gl.activeTexture(GL.TEXTURE0) ← 绑定纹理 A
├─ gl.activeTexture(GL.TEXTURE1) ← 绑定纹理 B
├─ gl.uniform*(loc, value) × 8 ← 写入 uniform含 vec2
├─ gl.bindBuffer + gl.vertexAttribPointer
└─ gl.drawArrays(GL_TRIANGLES, 0, 6) ← 1 个 draw call
[GPU Fragment Shader 并行执行]
管线 3 阶段(见 §6.2:
① UV parallax 偏移
② stripe 条纹交织 → mix(textureA, textureB, mask)
③ 输出修饰 (圆角/暗角/edgeAA)
[屏幕像素]
Canvas 上的最终像素显示给用户
每帧 ~200 万像素(以 375×500 CSS 尺寸 × DPR=2 计)
```
### 关键时序
| 环节 | 耗时估计 | 说明 |
|---|---|---|
| 陀螺仪采集 + 平滑 | < 0.5ms | JS 纯算术 |
| computeRenderState() + engineUniforms 汇出 | < 0.3ms | 10 层循环 + 浮点运算 |
| setUniforms + draw | < 1ms | WebGL 调用开销 |
| Fragment Shader 执行 | < 2ms | GPU 并行200 万像素 |
| 总计单帧 | < 4ms | 远低于 16ms 帧预算 |
---
## 六、Fragment Shader 管线
### 6.1 Uniform 清单(精简)
| uniform | 类型 | 来源 | 说明 |
|---|---|---|---|
| `u_textureA` | sampler2D | layer[0] 底图 | 背景纹理 |
| `u_textureB` | sampler2D | layer[1] 前景 | 人物纹理 |
| `u_parallax` | float | LenticularEngine | 视差偏移 -1~1 |
| `u_phase` | float | LenticularEngine | 条纹相位 0~1 |
| `u_density` | float | LenticularEngine | 条纹密度 ≈0.08~0.3 |
| `u_cornerRadius` | float | props | 圆角半径 |
| `u_resolution` | vec2 | canvas size | 分辨率 |
| `u_dpr` | float | devicePixelRatio | 像素比 |
**8 个 uniform**,无全息/时间相关参数。
### 6.2 管线3 阶段)
#### ① UV 变换
```glsl
vec2 uv = v_texCoord;
uv.x += u_parallax * 0.03;
float scale = 1.0 + abs(u_parallax) * 0.015;
uv = (uv - 0.5) * scale + 0.5;
```
#### ② 条纹交织(核心,仅此一个像素级效果)
```glsl
float stripe = fract(uv.x * u_density + u_phase);
float mask = smoothstep(0.45, 0.55, stripe);
vec4 baseColor = mix(texture2D(u_textureA, uv),
texture2D(u_textureB, uv),
mask);
```
**每个像素只来自一张图**,相邻像素交替采样 A/B。`u_density` 控制条纹密度(等效每毫米条纹数),`u_phase` 控制视角偏移时条纹组的左右滑动。这是整个引擎**唯一**的像素级视觉效果。
#### ③ 输出修饰
```glsl
// 圆角裁剪
float sdf = roundedRectSDF(pn, halfRes - cornerRadius, cornerRadius);
if (sdf > 1.5) discard;
// 暗角
baseColor *= 1.0 - pow(clamp(length(pnNorm) * 1.1, 0, 1), 2.8) * 0.35;
// 边缘抗锯齿
float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf);
gl_FragColor = vec4(baseColor, cornerMask * edgeAA);
```
无全息效果、无噪声、无色散、无高光、无划痕、无珠光。
---
## 七、组件 API
### 7.1 LenticularCard.vue
```vue
<LenticularCard
:layers="layers" // [{id, src, parallaxFactor, opacity}, ...]
:transforms.sync="..." // 保留CSS 降级时需要
gyro-source="simulation" // "simulation" | "gyro"
tilt-hint-text="倾斜手机预览"
corner-radius="24" // 圆角
webgl-preferred // Boolean默认 true
@simulate @ready @error
/>
```
使用方不感知渲染路径差异。
**WebGL 可用性判断**(在 `onMounted` 中按序执行):
1. `webglPreferred``false` → 直接使用 CSS DOM 路径,不尝试 WebGL
2. `webglPreferred``true` → 尝试创建 WebGL1 上下文
3. 创建成功 → 使用 WebGL 路径,发射 `@ready` 事件
4. 创建失败 → 自动降级到 CSS DOM 路径,发射 `@error` 事件(组件内不抛白屏)
**WebGL 初始化失败后重试**:不重试。一次失败代表该设备不支持 WebGL重试无意义。
### 组件内部结构
```vue
<template>
<view class="lenticular-container">
<canvas v-if="useWebgl" ref="webglCanvas" ... />
<view v-else class="lenticular-fallback">
<!-- 保留现有 CSS DOM 渲染代码 -->
<view v-for="layer in layers" :key="layer.id" class="fallback-layer"
:style="getLayerStyle(layer)">
<image :src="layer.src" mode="aspectFill" />
</view>
<view class="fallback-shimmer" />
<view class="fallback-rim" />
</view>
</view>
</template>
```
---
## 八、文件结构
### 新增
| # | 文件 | 职责 |
|---|---|---|
| 1 | `frontend/utils/lenticular-card/lenticular-webgl-shaders.js` | VERT_SRC + FRAG_SRC约 80 行,无全息效果) |
| 2 | `frontend/utils/lenticular-card/lenticular-webgl-engine.js` | `LenticularWebGLEngine`8 uniforms3 阶段管线) |
### 修改
| # | 文件 | 改动 |
|---|---|---|
| 3 | `frontend/utils/lenticular-engine.js` → 移入 `utils/lenticular-card/` | 文件迁移 + `computeRenderState()` 末尾汇出 `engineUniforms` |
| 4 | `frontend/composables/useLenticularPreview.js` | 追加 `engineUniforms` 到返回值 |
| 5 | `frontend/components/lenticular/LenticularCard.vue` | 双路径渲染 + 引擎生命周期 |
### 不改
- `useLenticularStudioTilt.js`
- `HolographicCard.vue` / `HolographicEngine` / `holographic-shaders.js`
- 所有页面层(`lenticular-create.vue` / `lenticular-result.vue` / `lenticular-thinking.vue`
- 所有镭射卡文件
### 目录对仗
```
utils/laser-card/ utils/lenticular-card/
├── laserGrating.js ├── lenticular-engine.js ← 迁入
├── laserPreviewWebgl.js ├── lenticular-webgl-engine.js ← 新增
├── laserBatchExport.js ├── lenticular-webgl-shaders.js ← 新增
├── laserPresets.js └── ...(后续光栅工具)
├── gacha.js
├── stylePool.js
└── ...
```
---
## 九、实施步骤
### Phase 1 — 核心引擎
1. 建 `frontend/utils/lenticular-card/` 目录,将 `lenticular-engine.js` 迁入
2. 编写 `lenticular-webgl-shaders.js`VERT_SRC + FRAG_SRC仅条纹交织 + 圆角暗角)
3. 编写 `lenticular-webgl-engine.js``LenticularWebGLEngine` 类8 uniform 管理)
4. `lenticular-engine.js` 追加 `engineUniforms` 汇出
5. `useLenticularPreview.js` 追加返回值
### Phase 2 — 组件集成
6. 重构 `LenticularCard.vue`:双路径 template + WebGL 引擎生命周期
7. 联调:验证陀螺仪 → engineUniforms → uniform → stripe shader 响应
### Phase 3 — 回归
8. 验证 CSS DOM 降级(`webglPreferred=false`
9. 验证 2 图层各场景(创建页拖动、结果页陀螺仪)
10. 低端安卓真机测试
---
## 十、DPR 与纹理管理策略
### 10.1 DPR 管理
- Canvas 物理分辨率 = CSS 尺寸 × `min(devicePixelRatio, 2)`
- Fragment shader 中 `u_dpr` 用于圆角 SDF 计算
- resize 时重建 viewport
### 10.2 纹理更新
`layers[].src` 变化时:
1. 加载新图片 → `engine.uploadTexture(index, image)` 更新对应纹理单元
2. 纹理使用 `gl.LINEAR_MIPMAP_LINEAR` 采样,上传后生成 mipmap
3. 初始化或任一纹理未就绪时,已就绪的纹理正常显示,未就绪的通道采样纯黑色
4. **shader 无需重新编译**——uniform 和纹理单元绑定不变,只换纹理数据
**纹理内存峰值**2 张纹理 × `(CSS宽×DPR) × (CSS高×DPR) × 4 bytes`。若卡片 CSS 尺寸为 375×500、DPR=2则单纹理约 1.5MB,合计约 3MB。在移动端可控。
### 10.3 生命周期
```
组件 onMounted
1. new LenticularWebGLEngine(canvas)
2. engine.init(textureA, textureB) // layer[0] + layer[1]
3. 注册 onTick 回调engine.setUniforms() + engine.draw()
4. useLenticularPreview.startRenderLoop() // 启动唯一 rAF
组件 watch layers[].src
→ 加载新图片 → engine.uploadTexture(index, newImage)
组件 onUnmounted
→ useLenticularPreview.stopRenderLoop()
→ engine.destroy()
```
### 10.4 rAF 循环 — 单一驱动
**不能有两套独立的 rAF**(一个来自 composable一个来自 engine否则帧同步紊乱。
改为 composable 的 rAF 作为唯一驱动源:
```
composable.tick() → feedSimulatedTilt → computeRenderState()
→ applyLayerTransformsFromRenderState() // CSS 降级用
→ callback(engineUniforms) // WebGL 用
└── component 注册的 onTick
└── engine.setUniforms(uniforms)
└── engine.draw()
→ requestAnimationFrame(tick)
```
实现方式:`useLenticularPreview` 新增一个 `onTick` 回调注册机制,组件在 `onMounted` 时传入:
```js
// LenticularCard.vue
const { ..., registerOnTick } = useLenticularPreview(layersRef)
registerOnTick((uniforms) => {
if (webglEngine) {
webglEngine.setUniforms(uniforms)
webglEngine.draw()
}
})
```
这样整个渲染管线**单帧内同步**完成,没有多 rAF 竞态。
---
## 十一、不影响的范围
- HolographicCard / HolographicEngine 保持独立,不参与本设计
- 页面层lenticular-create/lenticular-result/lenticular-thinking无需修改
- 镭射卡所有文件不受影响
- 后端无改动