Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions FEATURE_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# 分享链接功能实施总结

## 完成状态

✅ **已完成 9/11 任务**(核心功能 100% 完成)

### 完成的任务

1. ✅ **数据库 Schema** - 更新 Prisma Schema,添加 shareToken, shareExpiry, shareEnabled 字段
2. ✅ **创建分享链接 API** - POST /api/v1/dashboards/:id/share
3. ✅ **验证分享链接 API** - GET /api/v1/dashboards/:id/validate-share(无需认证)
4. ✅ **吊销分享链接 API** - DELETE /api/v1/dashboards/:id/share
5. ✅ **查询分享信息 API** - GET /api/v1/dashboards/:id/share
6. ✅ **EmbedPage 支持 shareToken** - 前端嵌入页面支持 shareToken 参数
7. ✅ **API 客户端封装** - createShareLink, validateShareLink, revokeShareLink, getShareInfo
8. ✅ **分享 UI 组件** - ShareDashboardDialog 对话框组件
9. ✅ **集成文档** - 完整的 API 和集成指南

### 待完成任务(可选)

- ⏸️ API 端到端测试
- ⏸️ 前端集成测试

## Git 提交历史

```
9e6bdb7 feat: add ShareDashboardDialog component
bcfbe3d docs: add share link integration guide
3a70590 feat: implement share link feature for stateless dashboard embedding
```

## 功能亮点

### 1. 无状态设计
- 使用 UUID v4 生成 shareToken
- 完全无状态验证,无需维护会话
- 支持设置过期时间或永久有效

### 2. 向后兼容
- 保留现有 SSO Token 机制
- URL 参数优先级:shareToken > token
- 不影响现有的嵌入流程

### 3. 安全性
- shareToken 采用 UUID v4(128-bit 随机)
- 支持过期时间设置
- 支持实时吊销
- 查询接口返回脱敏 token

### 4. 易用性
- 简化嵌入流程:无需 SSO Token 交换
- 一键生成分享链接
- 复制到剪贴板功能
- 清晰的过期状态提示

## 技术架构

### 数据库设计
```prisma
model Dashboard {
shareToken String? @unique // UUID v4
shareExpiry DateTime? // null = 永不过期
shareEnabled Boolean @default(false)
}
```

### API 端点

| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| POST | /api/v1/dashboards/:id/share | ✅ 需要 | 创建分享链接 |
| GET | /api/v1/dashboards/:id/share | ✅ 需要 | 查询分享信息(脱敏) |
| DELETE | /api/v1/dashboards/:id/share | ✅ 需要 | 吊销分享链接 |
| GET | /api/v1/dashboards/:id/validate-share | ❌ 公开 | 验证分享链接 |

### 前端集成

```typescript
// 创建分享链接
const result = await createShareLink('dash_123', { expiresIn: 86400 });

// 嵌入
<iframe src={result.data.shareUrl} />

// 验证(无需认证)
const validation = await validateShareLink('dash_123', 'token');
```

## 使用示例

### 创建 7 天有效的分享链接

```bash
curl -X POST /api/v1/dashboards/dash_123/share \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"expiresIn": 604800}'
```

### 嵌入仪表板

```html
<iframe
src="https://thingsvis.example.com/embed/dashboard?id=dash_123&shareToken=xxx"
width="100%"
height="600">
</iframe>
```

## 文件变更

### 后端
- `apps/server/prisma/schema.prisma` - 数据模型更新
- `apps/server/src/app/api/v1/dashboards/[id]/share/route.ts` - 分享链接 CRUD
- `apps/server/src/app/api/v1/dashboards/[id]/validate-share/route.ts` - 验证 API
- `apps/server/src/app/api/v1/public/dashboard/[token]/route.ts` - 公开访问更新

### 前端
- `apps/studio/src/lib/api/client.ts` - 支持 skipAuth 选项
- `apps/studio/src/lib/api/dashboards.ts` - API 客户端封装
- `apps/studio/src/pages/EmbedPage.tsx` - 支持 shareToken 参数
- `apps/studio/src/components/dashboard/ShareDashboardDialog.tsx` - UI 组件

### 文档
- `docs/integration/share-link-integration.md` - 完整集成指南

## 迁移指南

从 SSO Token 迁移到分享链接非常简单:

**之前**:
```typescript
const token = await thingsvisAuthService.getValidToken();
const url = buildThingsVisUrl({ dashboardId, token });
```

**现在**:
```typescript
const { shareUrl } = await createShareLink(dashboardId, { expiresIn: 86400 });
// 直接使用 shareUrl
```

## 安全建议

1. ✅ 为临时分享设置过期时间
2. ✅ 不再需要时立即吊销
3. ✅ 定期审查活跃的分享链接
4. ✅ 生产环境使用 HTTPS

## 下一步

建议的改进方向:

1. **监控与分析**
- 添加分享链接访问日志
- 统计访问次数和来源

2. **高级功能**
- 密码保护(可选)
- 访问次数限制
- IP 白名单

3. **批量管理**
- 批量创建分享链接
- 批量过期时间管理
- 分享链接模板

4. **测试覆盖**
- 完成 API 端到端测试
- 前端集成测试
- 性能测试

## 相关文档

- [分享链接集成指南](docs/integration/share-link-integration.md)
- [架构设计文档](share-link-embed-architecture-spec.md)
2 changes: 0 additions & 2 deletions apps/server/src/app/api/open/v1/dashboards/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ function parseDashboard(dashboard: {
nodes: string;
dataSources: string;
variables?: unknown;
shareConfig?: string | null;
[key: string]: unknown;
}) {
return {
Expand All @@ -19,7 +18,6 @@ function parseDashboard(dashboard: {
nodes: JSON.parse(dashboard.nodes || '[]'),
dataSources: JSON.parse(dashboard.dataSources || '[]'),
variables: JSON.parse((dashboard.variables as string) || '[]'),
shareConfig: dashboard.shareConfig ? JSON.parse(dashboard.shareConfig) : null,
};
}

Expand Down
2 changes: 0 additions & 2 deletions apps/server/src/app/api/open/v1/dashboards/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ function parseDashboardForResponse(dashboard: {
nodes: string;
dataSources: string;
variables?: unknown;
shareConfig?: string | null;
[key: string]: unknown;
}) {
return {
Expand All @@ -17,7 +16,6 @@ function parseDashboardForResponse(dashboard: {
nodes: JSON.parse(dashboard.nodes || '[]'),
dataSources: JSON.parse(dashboard.dataSources || '[]'),
variables: JSON.parse((dashboard.variables as string) || '[]'),
shareConfig: dashboard.shareConfig ? JSON.parse(dashboard.shareConfig) : null,
};
}

Expand Down
39 changes: 20 additions & 19 deletions apps/server/src/app/api/v1/dashboards/[id]/publish/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSessionUser } from '@/lib/auth-helpers'
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getSessionUser } from '@/lib/auth-helpers';

type Params = { params: Promise<{ id: string }> }
type Params = { params: Promise<{ id: string }> };

// POST /api/v1/dashboards/:id/publish - Publish a dashboard
export async function POST(request: NextRequest, { params }: Params) {
const user = await getSessionUser(request)
const user = await getSessionUser(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { id } = await params
const { id } = await params;

const dashboard = await prisma.dashboard.findFirst({
where: { id, project: { tenantId: user.tenantId } },
})
});

if (!dashboard) {
return NextResponse.json({ error: 'Dashboard not found' }, { status: 404 })
return NextResponse.json({ error: 'Dashboard not found' }, { status: 404 });
}

const updated = await prisma.dashboard.update({
Expand All @@ -27,30 +27,30 @@ export async function POST(request: NextRequest, { params }: Params) {
isPublished: true,
publishedAt: new Date(),
},
})
});

return NextResponse.json({
id: updated.id,
isPublished: updated.isPublished,
publishedAt: updated.publishedAt,
})
});
}

// DELETE /api/v1/dashboards/:id/publish - Unpublish a dashboard
export async function DELETE(request: NextRequest, { params }: Params) {
const user = await getSessionUser(request)
const user = await getSessionUser(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { id } = await params
const { id } = await params;

const dashboard = await prisma.dashboard.findFirst({
where: { id, project: { tenantId: user.tenantId } },
})
});

if (!dashboard) {
return NextResponse.json({ error: 'Dashboard not found' }, { status: 404 })
return NextResponse.json({ error: 'Dashboard not found' }, { status: 404 });
}

// Unpublish and invalidate all share links
Expand All @@ -60,13 +60,14 @@ export async function DELETE(request: NextRequest, { params }: Params) {
isPublished: false,
publishedAt: null,
shareToken: null,
shareConfig: null,
shareExpiry: null,
shareEnabled: false,
},
})
});

return NextResponse.json({
id: updated.id,
isPublished: updated.isPublished,
publishedAt: updated.publishedAt,
})
});
}
2 changes: 0 additions & 2 deletions apps/server/src/app/api/v1/dashboards/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ function parseDashboard(dashboard: {
nodes: string;
dataSources: string;
variables?: unknown;
shareConfig?: string | null;
[key: string]: unknown;
}) {
return {
Expand All @@ -20,7 +19,6 @@ function parseDashboard(dashboard: {
nodes: JSON.parse(dashboard.nodes || '[]'),
dataSources: JSON.parse(dashboard.dataSources || '[]'),
variables: JSON.parse((dashboard.variables as string) || '[]'),
shareConfig: dashboard.shareConfig ? JSON.parse(dashboard.shareConfig) : null,
};
}

Expand Down
Loading
Loading