diff --git a/20260905-bookmark-import.md b/20260905-bookmark-import.md new file mode 100644 index 0000000..ae421e6 --- /dev/null +++ b/20260905-bookmark-import.md @@ -0,0 +1,9 @@ +# 导入时保留原保存时间 + +日期:2026-09-05 + +- 用户书签创建接口增加可选导入参数,写入原保存时间和加星状态。 +- 导入参数存在时,已有用户书签关系不更新;普通重新保存仍使用当前时间。 +- 导入变更记录和通知使用当前时间,避免旧保存日期导致增量同步漏掉新增书签。 +- 正式版后端的导入测试验证日期、状态、已有关系和同步时间。无数据库迁移。 +- 测试隔离 Prisma 运行时,CI 无需先生成数据库客户端即可执行。 diff --git a/src/domain/bookmark.ts b/src/domain/bookmark.ts index 8dc3f23..5ac8361 100644 --- a/src/domain/bookmark.ts +++ b/src/domain/bookmark.ts @@ -76,6 +76,9 @@ export interface addBookmarkReq { } export interface addUrlBookmarkReq { + saved_at?: string + is_starred?: boolean + import_only?: boolean target_url: string target_title?: string thumbnail?: string @@ -120,6 +123,7 @@ export class BookmarkService { description?: string siteName?: string isArchive?: boolean + importMetadata?: { savedAt?: Date; starred: boolean } }) { if (options.type === 1) { const urlEntity = new URL(options.targetUrl) @@ -154,7 +158,7 @@ export class BookmarkService { const [_, relation] = await Promise.all([ this.bookmarkSearchRepo.upsertUserBookmark(options.ctx.getUserId(), bmInfo.id), - this.bookmarkRepo.createBookmarkRelation(options.ctx.getUserId(), bmInfo.id, options.type, options.isArchive || false) + this.bookmarkRepo.createBookmarkRelation(options.ctx.getUserId(), bmInfo.id, options.type, options.isArchive || false, options.importMetadata) ]) if (relation.deleted_at) { @@ -164,12 +168,13 @@ export class BookmarkService { if (relation) { // 在数据库中增加收藏添加记录 try { - await this.bookmarkRepo.createBookmarkChangeLog(options.ctx.getUserId(), options.targetUrl, relation.bookmark_id, 'add', relation.created_at) + const changeTime = options.importMetadata ? new Date() : relation.created_at + await this.bookmarkRepo.createBookmarkChangeLog(options.ctx.getUserId(), options.targetUrl, relation.bookmark_id, 'add', changeTime) options.ctx.execution.waitUntil( this.notifyMessage.sendBookmarkChange(options.ctx.env, { user_id: options.ctx.getUserId(), bookmark_id: options.ctx.hashIds.encodeId(relation.bookmark_id), - created_at: relation.created_at, + created_at: changeTime, target_url: options.targetUrl, action: 'add' }) diff --git a/src/infra/repository/dbBookmark.ts b/src/infra/repository/dbBookmark.ts index ec88fd0..5d62aa2 100644 --- a/src/infra/repository/dbBookmark.ts +++ b/src/infra/repository/dbBookmark.ts @@ -255,12 +255,20 @@ export class BookmarkRepo { }) } - public async createBookmarkRelation(userId: number, bmId: number, type: number, isArchive: boolean) { + public async createBookmarkRelation(userId: number, bmId: number, type: number, isArchive: boolean, importMetadata?: { savedAt?: Date; starred: boolean }) { // re-save bumps created_at (save time, tops inbox); replay may re-top return await this.prismaPg().sr_user_bookmark.upsert({ where: { user_id_bookmark_id: { user_id: userId, bookmark_id: bmId } }, - create: { user_id: userId, bookmark_id: bmId, created_at: new Date(), updated_at: new Date(), type, archive_status: isArchive ? 1 : 0 }, - update: { created_at: new Date(), updated_at: new Date() } + create: { + user_id: userId, + bookmark_id: bmId, + created_at: importMetadata?.savedAt ?? new Date(), + updated_at: new Date(), + type, + archive_status: isArchive ? 1 : 0, + is_starred: importMetadata?.starred ?? false + }, + update: importMetadata ? {} : { created_at: new Date(), updated_at: new Date() } }) } diff --git a/test/bookmarkImportMetadata.test.ts b/test/bookmarkImportMetadata.test.ts new file mode 100644 index 0000000..8b0d3ed --- /dev/null +++ b/test/bookmarkImportMetadata.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@prisma/client', () => ({ + Prisma: { join: vi.fn(), sql: vi.fn() }, + PrismaClient: class {} +})) + +vi.mock('@prisma/hyperdrive-client', () => ({ + PrismaClient: class {} +})) + +import { BookmarkService } from '../src/domain/bookmark' +import { BookmarkRepo, queueStatus } from '../src/infra/repository/dbBookmark' + +describe('bookmark import metadata', () => { + it('uses saved time and stars for new relations without changing an existing relation', async () => { + const repo = Object.create(BookmarkRepo.prototype) as BookmarkRepo + const upsert = vi.fn().mockResolvedValue({}) + Object.assign(repo, { prismaPg: () => ({ sr_user_bookmark: { upsert } }) }) + const savedAt = new Date('2020-01-02T03:04:05Z') + + await repo.createBookmarkRelation(1, 42, 0, true, { savedAt, starred: true }) + + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ created_at: savedAt, is_starred: true, archive_status: 1 }), + update: {} + }) + ) + + await repo.createBookmarkRelation(1, 42, 0, false) + expect(upsert.mock.calls[1][0].update.created_at).toBeInstanceOf(Date) + }) + + it('uses current change-log time while preserving the original saved date', async () => { + const service = Object.create(BookmarkService.prototype) as BookmarkService + const oldDate = new Date('2020-01-02T03:04:05Z') + const createBookmarkRelation = vi.fn().mockResolvedValue({ bookmark_id: 42, created_at: oldDate, deleted_at: null }) + const createBookmarkChangeLog = vi.fn() + const sendBookmarkChange = vi.fn().mockResolvedValue(undefined) + const waitUntil = vi.fn() + Object.assign(service, { + bookmarkRepo: { + createBookmark: vi.fn().mockResolvedValue({ id: 42, status: queueStatus.SUCCESS }), + createBookmarkRelation, + createBookmarkChangeLog + }, + bookmarkSearchRepo: { upsertUserBookmark: vi.fn() }, + notifyMessage: { sendBookmarkChange } + }) + const ctx = { + env: {}, + execution: { waitUntil }, + getUserId: () => 1, + hashIds: { encodeId: (id: number) => `encoded-${id}` } + } as any + const before = Date.now() + + await service.createBookmarkBase({ + ctx, + targetUrl: 'https://example.com/article', + hostUrl: 'example.com', + title: 'Article', + type: 0, + importMetadata: { savedAt: oldDate, starred: false } + }) + + expect(createBookmarkRelation).toHaveBeenCalledWith(1, 42, 0, false, { savedAt: oldDate, starred: false }) + const changeTime = createBookmarkChangeLog.mock.calls[0][4] as Date + expect(changeTime.getTime()).toBeGreaterThanOrEqual(before) + expect(sendBookmarkChange).toHaveBeenCalledWith(ctx.env, expect.objectContaining({ created_at: changeTime, bookmark_id: 'encoded-42' })) + expect(waitUntil).toHaveBeenCalledTimes(1) + }) +})