Skip to content

Commit d1d4dd6

Browse files
authored
Merge pull request #438 from fengshao1227/feat/user-export-all
feat: allow users to export all their conversations
2 parents ab14ce8 + bbec891 commit d1d4dd6

17 files changed

Lines changed: 313 additions & 12 deletions

File tree

backend/internal/application/conversation/service_conversation.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,19 @@ func (s *Service) ListAllConversationsAfterID(ctx context.Context, afterID uint,
181181
return s.repo.ListAllConversationsAfterID(ctx, afterID, limit)
182182
}
183183

184+
// ListUserConversationsAfterID 按主键游标分页列出指定用户的会话。
185+
func (s *Service) ListUserConversationsAfterID(ctx context.Context, userID uint, afterID uint, limit int) ([]model.Conversation, error) {
186+
return s.repo.ListUserConversationsAfterID(ctx, userID, afterID, limit)
187+
}
188+
189+
// ExportUserConversationData 导出单会话完整数据,校验用户归属。
190+
func (s *Service) ExportUserConversationData(ctx context.Context, userID uint, conversation *model.Conversation) (*ConversationExportResult, error) {
191+
if conversation.UserID != userID {
192+
return nil, ErrConversationNotFound
193+
}
194+
return s.ExportConversationData(ctx, conversation)
195+
}
196+
184197
// ExportConversationData 导出单会话完整数据,不做用户归属校验(管理员用)。
185198
func (s *Service) ExportConversationData(ctx context.Context, conversation *model.Conversation) (*ConversationExportResult, error) {
186199
items, err := s.repo.ListAllMessages(ctx, conversation.ID)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package conversation
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
8+
)
9+
10+
func TestExportUserConversationDataRejectsWrongUser(t *testing.T) {
11+
svc := &Service{}
12+
conv := &model.Conversation{ID: 1, UserID: 42}
13+
14+
_, err := svc.ExportUserConversationData(nil, 99, conv)
15+
if !errors.Is(err, ErrConversationNotFound) {
16+
t.Fatalf("expected ErrConversationNotFound, got %v", err)
17+
}
18+
}
19+
20+
func TestExportDefaultMessagePublicIDsFiltersVisibleBranch(t *testing.T) {
21+
rootID := uint(1)
22+
messages := []model.Message{
23+
{ID: 1, PublicID: "msg_user", Role: "user", Status: "success"},
24+
{ID: 2, PublicID: "msg_assistant_v1", ParentMessageID: &rootID, Role: "assistant", Status: "success"},
25+
{ID: 3, PublicID: "msg_assistant_v2", ParentMessageID: &rootID, Role: "assistant", Status: "success"},
26+
}
27+
ids := exportDefaultMessagePublicIDs(messages)
28+
if len(ids) == 0 {
29+
t.Fatal("expected non-empty default message IDs")
30+
}
31+
for _, id := range ids {
32+
if id == "" {
33+
t.Error("default message public ID should not be empty")
34+
}
35+
}
36+
}
37+
38+
func TestCollectExportMessageRunIDsDeduplicates(t *testing.T) {
39+
messages := []model.Message{
40+
{RunID: "run_1"},
41+
{RunID: "run_2"},
42+
{RunID: "run_1"},
43+
{RunID: ""},
44+
{RunID: "run_3"},
45+
}
46+
runIDs := collectExportMessageRunIDs(messages)
47+
if len(runIDs) != 3 {
48+
t.Fatalf("expected 3 unique run IDs, got %d: %v", len(runIDs), runIDs)
49+
}
50+
expected := map[string]bool{"run_1": true, "run_2": true, "run_3": true}
51+
for _, id := range runIDs {
52+
if !expected[id] {
53+
t.Errorf("unexpected run ID: %s", id)
54+
}
55+
}
56+
}
57+
58+
func TestCollectExportMessageRunIDsSkipsEmpty(t *testing.T) {
59+
messages := []model.Message{
60+
{RunID: ""},
61+
{RunID: " "},
62+
}
63+
runIDs := collectExportMessageRunIDs(messages)
64+
if len(runIDs) != 0 {
65+
t.Fatalf("expected 0 run IDs for empty inputs, got %d", len(runIDs))
66+
}
67+
}

backend/internal/infra/persistence/postgres/conversation/repository.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,19 @@ func (r *Repo) ListAllConversationsAfterID(ctx context.Context, afterID uint, li
812812
return toConversationDomains(rows), nil
813813
}
814814

815+
// ListUserConversationsAfterID 按主键游标分页列出指定用户的会话。
816+
func (r *Repo) ListUserConversationsAfterID(ctx context.Context, userID uint, afterID uint, limit int) ([]domainconversation.Conversation, error) {
817+
var rows []models.Conversation
818+
query := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("id ASC").Limit(limit)
819+
if afterID > 0 {
820+
query = query.Where("id > ?", afterID)
821+
}
822+
if err := query.Find(&rows).Error; err != nil {
823+
return nil, translateError(err)
824+
}
825+
return toConversationDomains(rows), nil
826+
}
827+
815828
// CreateMessage 创建消息。
816829
func (r *Repo) CreateMessage(ctx context.Context, item *domainconversation.Message) error {
817830
attachmentSnapshot := item.Attachments

backend/internal/repository/conversation_core.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ type ConversationMetadataRepository interface {
7171
UpdateConversationStatefulResponse(ctx context.Context, conversationID uint, responseID string, promptFingerprint string) error
7272
UpdateConversationModel(ctx context.Context, conversationID uint, platformModelName string, provider string) error
7373
ListAllConversationsAfterID(ctx context.Context, afterID uint, limit int) ([]domainconversation.Conversation, error)
74+
ListUserConversationsAfterID(ctx context.Context, userID uint, afterID uint, limit int) ([]domainconversation.Conversation, error)
7475
}
7576

7677
// MessageRepository 封装消息读写能力。

backend/internal/transport/http/conversation/handler_conversation.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package conversation
22

33
import (
4+
"encoding/json"
45
"errors"
6+
"fmt"
57
"net/http"
68
"strconv"
9+
"time"
710

811
appconversation "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/conversation"
912
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/response"
@@ -187,6 +190,84 @@ func (h *Handler) ExportConversation(c *gin.Context) {
187190
response.Success(c, toConversationExportResponse(item))
188191
}
189192

193+
type userExportManifest struct {
194+
Type string `json:"_type"`
195+
Complete bool `json:"complete"`
196+
Exported int64 `json:"exported"`
197+
Failed int `json:"failed"`
198+
FailedIDs []uint `json:"failedIDs,omitempty"`
199+
Error string `json:"error,omitempty"`
200+
}
201+
202+
// ExportAllConversations godoc
203+
// @Summary 导出当前用户全部对话
204+
// @Description 流式导出当前用户全部会话及消息为 NDJSON 文件
205+
// @Tags chat
206+
// @Produce application/x-ndjson
207+
// @Security BearerAuth
208+
// @Success 200 {string} string "NDJSON stream"
209+
// @Failure 500 {object} ErrorDoc
210+
// @Router /conversations/export [get]
211+
func (h *Handler) ExportAllConversations(c *gin.Context) {
212+
userID := middleware.MustUserID(c)
213+
214+
h.recordAudit(c, "export_all_conversations", "conversation", "", map[string]interface{}{"scope": "user"})
215+
216+
c.Header("Content-Type", "application/x-ndjson")
217+
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="my-conversations-%s.jsonl"`, time.Now().UTC().Format("20060102-150405")))
218+
c.Header("Cache-Control", "no-store")
219+
c.Status(http.StatusOK)
220+
221+
const batchSize = 50
222+
var lastID uint
223+
encoder := json.NewEncoder(c.Writer)
224+
exported := int64(0)
225+
var failedIDs []uint
226+
writeManifest := func(complete bool, exportErr string) {
227+
_ = encoder.Encode(userExportManifest{
228+
Type: "export_manifest",
229+
Complete: complete,
230+
Exported: exported,
231+
Failed: len(failedIDs),
232+
FailedIDs: failedIDs,
233+
Error: exportErr,
234+
})
235+
c.Writer.Flush()
236+
}
237+
238+
for {
239+
if c.Request.Context().Err() != nil {
240+
return
241+
}
242+
conversations, err := h.service.ListUserConversationsAfterID(c.Request.Context(), userID, lastID, batchSize)
243+
if err != nil {
244+
writeManifest(false, "failed to list conversations")
245+
return
246+
}
247+
if len(conversations) == 0 {
248+
break
249+
}
250+
for i := range conversations {
251+
result, err := h.service.ExportUserConversationData(c.Request.Context(), userID, &conversations[i])
252+
if err != nil {
253+
failedIDs = append(failedIDs, conversations[i].ID)
254+
continue
255+
}
256+
if err := encoder.Encode(ToConversationExportResponse(result)); err != nil {
257+
return
258+
}
259+
exported++
260+
}
261+
c.Writer.Flush()
262+
lastID = conversations[len(conversations)-1].ID
263+
if len(conversations) < batchSize {
264+
break
265+
}
266+
}
267+
268+
writeManifest(true, "")
269+
}
270+
190271
// RenameConversation godoc
191272
// @Summary 重命名会话
192273
// @Description 修改指定会话标题

backend/internal/transport/http/conversation/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func (m *Module) RegisterRoutes(authRequired *gin.RouterGroup) {
1414
authRequired.PATCH("/conversation-projects/:id", m.Handler.UpdateConversationProject)
1515
authRequired.DELETE("/conversation-projects/:id", m.Handler.DeleteConversationProject)
1616
authRequired.POST("/conversations/project", m.Handler.BatchSetConversationProject)
17+
authRequired.GET("/conversations/export", m.Handler.ExportAllConversations)
1718
authRequired.GET("/conversations/:id", m.Handler.GetConversation)
1819
authRequired.GET("/conversations/:id/export", m.Handler.ExportConversation)
1920
authRequired.PATCH("/conversations/:id/title", m.Handler.RenameConversation)

frontend/features/admin/api/settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ function contentDispositionFileName(value: string | null): string | null {
140140

141141
export async function exportAllConversations(accessToken: string): Promise<AdminConversationExportFile> {
142142
const response = await authedFetch("/api/v1/admin/conversations/export", { accessToken });
143+
if (!response.ok) {
144+
throw new Error(`export failed: ${response.status}`);
145+
}
143146
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
144147
return {
145148
blob: await response.blob(),

frontend/features/admin/components/sections/conversation/admin-conversation.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "@/components/ui/dialog";
2222
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2323
import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
24+
import { downloadBlob, readExportManifest } from "@/shared/lib/export-download";
2425
import {
2526
SettingsFieldInset,
2627
SettingsFieldItem,
@@ -387,15 +388,13 @@ export function AdminConversationSettingsPage() {
387388
const token = await resolveAccessToken();
388389
if (!token) return;
389390
const { blob, fileName } = await exportAllConversations(token);
390-
const url = URL.createObjectURL(blob);
391-
const link = document.createElement("a");
392-
link.href = url;
393-
link.download = fileName;
394-
link.rel = "noopener";
395-
document.body.appendChild(link);
396-
link.click();
397-
link.remove();
398-
URL.revokeObjectURL(url);
391+
const manifest = await readExportManifest(blob);
392+
downloadBlob(blob, fileName);
393+
if (manifest && (!manifest.complete || (manifest.failed ?? 0) > 0)) {
394+
toast.warning(t("dataExport.partial", { exported: manifest.exported ?? 0, failed: manifest.failed ?? 0 }));
395+
} else if (manifest) {
396+
toast.success(t("dataExport.success", { count: manifest.exported ?? 0 }));
397+
}
399398
} catch {
400399
toast.error(t("dataExport.failed"));
401400
} finally {

frontend/features/recent/components/app-recent.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export function AppRecent() {
7474
onArchiveSelected={requestArchiveSelected}
7575
onRevokeSelectedShares={requestRevokeSelectedShares}
7676
onRequestDeleteSelected={controller.requestDeleteSelected}
77+
onExportAll={controller.onExportAll}
78+
exportingAll={controller.exportingAll}
7779
onStatusFilterChange={controller.setStatusFilter}
7880
onStarredFilterChange={controller.setStarredFilter}
7981
onShareFilterChange={controller.setShareFilter}

frontend/features/recent/components/sections/recent-toolbar.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import * as React from "react";
4-
import { Archive, Link2Off, Trash, X } from "lucide-react";
4+
import { Archive, Download, Link2Off, SquareMousePointer, Trash, X } from "lucide-react";
55
import { useTranslations } from "next-intl";
66

77
import { RecentFilterGroup } from "@/features/recent/components/sections/recent-filter-group";
@@ -33,6 +33,8 @@ type RecentToolbarProps = {
3333
onArchiveSelected: () => void | Promise<void>;
3434
onRevokeSelectedShares: () => void | Promise<void>;
3535
onRequestDeleteSelected: () => void;
36+
onExportAll: () => void | Promise<void>;
37+
exportingAll: boolean;
3638
onStatusFilterChange: (value: ConversationStatusFilter) => void;
3739
onStarredFilterChange: (value: ConversationStarredFilter) => void;
3840
onShareFilterChange: (value: ConversationShareFilter) => void;
@@ -53,6 +55,8 @@ export function RecentToolbar({
5355
onArchiveSelected,
5456
onRevokeSelectedShares,
5557
onRequestDeleteSelected,
58+
onExportAll,
59+
exportingAll,
5660
onStatusFilterChange,
5761
onStarredFilterChange,
5862
onShareFilterChange,
@@ -175,10 +179,27 @@ export function RecentToolbar({
175179
<span className="hidden md:inline">{t("allConversationsDescription")}</span>
176180
<button
177181
type="button"
178-
className="shrink-0 underline underline-offset-4 transition-colors hover:text-foreground"
182+
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground"
179183
onClick={onEnterSelectionMode}
184+
aria-label={t("enterSelection")}
185+
title={t("enterSelection")}
180186
>
181-
{t("select")}
187+
<SquareMousePointer className="size-4" strokeWidth={1.4} />
188+
</button>
189+
<button
190+
type="button"
191+
className={cn(
192+
"inline-flex size-6 items-center justify-center rounded-md transition-colors",
193+
exportingAll
194+
? "text-muted-foreground/50"
195+
: "text-foreground/60 hover:bg-accent hover:text-foreground",
196+
)}
197+
disabled={exportingAll}
198+
onClick={() => void onExportAll()}
199+
aria-label={t("exportAll")}
200+
title={t("exportAll")}
201+
>
202+
<Download className="size-4" strokeWidth={1.4} />
182203
</button>
183204
</div>
184205

0 commit comments

Comments
 (0)