From 4f61f74bf15c5ae58d8890ec96f613b7f4b7848e Mon Sep 17 00:00:00 2001 From: wulujia Date: Tue, 8 Sep 2026 17:35:21 +0800 Subject: [PATCH 1/2] feat: export saved links with account-scoped pagination --- 20260908-bookmark-export.md | 19 ++++++++ src/di/generated/readerRouter.ts | 4 ++ src/domain/bookmark.ts | 66 ++++++++++++++++++++++++++ src/handler/http/bookmarkController.ts | 8 ++++ src/infra/repository/dbBookmark.ts | 32 +++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 20260908-bookmark-export.md diff --git a/20260908-bookmark-export.md b/20260908-bookmark-export.md new file mode 100644 index 0000000..0e10725 --- /dev/null +++ b/20260908-bookmark-export.md @@ -0,0 +1,19 @@ +# 收藏链接导出 + +日期:2026-09-08 + +新增需要登录的 `GET /v1/bookmark/export`,托管服务和开源 API 共用查询和转换逻辑。每页最多返回 500 条记录,响应为 `{ data: { items, next_cursor }, code, message }`。请求只接受可选的 `cursor`,账户归属从登录上下文读取。响应禁止缓存。 + +导出范围包含归档、未读、已读、星标、网址快捷方式和抓取中或抓取失败的文章;排除回收站。链接标题依次取用户自定标题、文章标题、原始网址。标签保留所有有效关联,包括 AI 标签,`source` 按原值输出。 + +每条记录字段为 `url`、`title`、`tags`(含 `name`、`source`)、`saved_at`(UTC ISO 时间)、`is_read`、`is_archived`、`is_starred`、`type`(`article` 或 `shortcut`)。三个状态分别读取,归档状态仅在 `archive_status = 1` 时为真。记录没有文章关联时,整页失败,避免生成缺失网址的导出文件。 + +首个请求记录账户收藏关系的最大 ID,后续按关系 ID 升序读取。新增加的较大 ID 不进入本次导出,删除记录不会造成后续页跳项。各页元数据以查询时的状态为准。游标是带版本和账户范围的 Base64URL 数据,不作为权限凭证;所有数据库查询仍按登录账户过滤。 + +无需数据库迁移。发布时先部署后端,再部署前端;本次仅修改代码,未部署。 + +验证:46 项定向测试通过,覆盖导出内容、账户过滤、标签查询范围、标题回退、连续分页、增删期间分页、无效游标、空库、失败后重试和控制器响应。测试使用内存数据库替身验证实际仓储查询参数,未连接生产数据库。 + +类型检查发现 24 条现有错误,涉及 MCP 依赖类型、WebSocket、Google 登录环境变量等未修改位置,导出代码无类型错误。托管控制器、路由和共享层的定向 lint 均无错误,保留现有警告。共享层使用自身 lint 配置检查。 + +补充验证:通过真实生成路由和鉴权中间件测试缺失会话与已过期 JWT,均在解析控制器和查询数据库前拒绝请求。另验证两页之间修改的标题、已读、归档和星标状态会出现在下一页。 diff --git a/src/di/generated/readerRouter.ts b/src/di/generated/readerRouter.ts index 2275211..e771316 100644 --- a/src/di/generated/readerRouter.ts +++ b/src/di/generated/readerRouter.ts @@ -29,6 +29,10 @@ export function getRouter(container: Container) { const controller = container.resolve(AigcController) return await controller.handleCompletionsRequest(ctx, req) }) + router.get('/v1/bookmark/export', async (req: Request, ctx: ContextManager) => { + const controller = container.resolve(BookmarkController) + return await controller.handleUserExportBookmarksRequest(ctx, req) + }) router.post('/v1/bookmark/add', async (req: Request, ctx: ContextManager) => { const controller = container.resolve(BookmarkController) return await controller.handleUserAddBookmarkRequest(ctx, req) diff --git a/src/domain/bookmark.ts b/src/domain/bookmark.ts index 618d59d..d8c4660 100644 --- a/src/domain/bookmark.ts +++ b/src/domain/bookmark.ts @@ -25,6 +25,22 @@ import { selectDORegion } from '../utils/location' import { NotificationMessage } from '../infra/message/notification' import { Hashid } from '../utils/hashids' +export interface BookmarkExportItem { + url: string + title: string + tags: { name: string; source: string }[] + saved_at: string + is_read: boolean + is_archived: boolean + is_starred: boolean + type: 'article' | 'shortcut' +} + +export interface BookmarkExportResponse { + items: BookmarkExportItem[] + next_cursor: string | null +} + export interface BookmarkDetailResp { bookmark_id?: number bookmark_user_uuid?: string @@ -506,6 +522,56 @@ export class BookmarkService { } } + public async exportBookmarks(ctx: ContextManager, cursor: string | null): Promise { + const userId = ctx.getUserId() + let afterId = 0 + let upperId: number + if (cursor !== null) { + try { + if (cursor.length > 256 || !/^[A-Za-z0-9_-]+$/.test(cursor)) throw new Error('Invalid cursor') + const decoded = JSON.parse(atob(cursor.replace(/-/g, '+').replace(/_/g, '/'))) + if ( + decoded.v !== 1 || + decoded.user !== userId || + !Number.isSafeInteger(decoded.after) || + !Number.isSafeInteger(decoded.upper) || + decoded.after < 1 || + decoded.upper < decoded.after + ) + throw new Error('Invalid cursor') + afterId = decoded.after + upperId = decoded.upper + } catch { + throw ErrorParam() + } + } else { + upperId = await this.bookmarkRepo.getExportUpperId(userId) + } + + if (upperId === 0) return { items: [], next_cursor: null } + const rows = await this.bookmarkRepo.listExportBookmarks(userId, afterId, upperId) + const page = rows.slice(0, 500) + if (page.some(row => !row.bookmark)) throw new Error('Saved link has no bookmark record') + const items: BookmarkExportItem[] = page.map(row => ({ + url: row.bookmark!.target_url, + title: row.alias_title || row.bookmark!.title || row.bookmark!.target_url, + tags: row.sr_user_bookmark_tag.map(tag => ({ name: tag.tag_name, source: tag.source })), + saved_at: row.created_at.toISOString(), + is_read: row.is_read, + is_archived: row.archive_status === 1, + is_starred: row.is_starred, + type: row.type === 1 ? 'shortcut' : 'article' + })) + const next_cursor = + rows.length > 500 + ? btoa(JSON.stringify({ v: 1, user: userId, after: page[page.length - 1].id, upper: upperId })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + : null + return { items, next_cursor } + } + /** one list row for the client: bookmark fields + user state + live tag chips */ private mapUserBookmarkRows(ctx: ContextManager, rows: UserBookmarkListRow[]) { return rows diff --git a/src/handler/http/bookmarkController.ts b/src/handler/http/bookmarkController.ts index 76bc3c4..f7ffc20 100644 --- a/src/handler/http/bookmarkController.ts +++ b/src/handler/http/bookmarkController.ts @@ -27,6 +27,14 @@ export class BookmarkController { @inject(UrlParserHandler) private urlParserHandler: UrlParserHandler ) {} + @Get('/export') + public async handleUserExportBookmarksRequest(ctx: ContextManager, request: Request) { + const cursor = new URL(request.url).searchParams.get('cursor') + const response = Successed(await this.bookmarkService.exportBookmarks(ctx, cursor)) + response.headers.set('Cache-Control', 'private, no-store') + return response + } + /** * 新增收藏 */ diff --git a/src/infra/repository/dbBookmark.ts b/src/infra/repository/dbBookmark.ts index f64e717..25bab36 100644 --- a/src/infra/repository/dbBookmark.ts +++ b/src/infra/repository/dbBookmark.ts @@ -289,6 +289,38 @@ export class BookmarkRepo { }) } + public async getExportUpperId(userId: number): Promise { + const row = await this.prismaPg().sr_user_bookmark.findFirst({ + where: { user_id: userId, deleted_at: null }, + orderBy: { id: 'desc' }, + select: { id: true } + }) + return row?.id ?? 0 + } + + public async listExportBookmarks(userId: number, afterId: number, upperId: number) { + return this.prismaPg().sr_user_bookmark.findMany({ + where: { user_id: userId, deleted_at: null, id: { gt: afterId, lte: upperId } }, + orderBy: { id: 'asc' }, + take: 501, + select: { + id: true, + alias_title: true, + created_at: true, + is_read: true, + archive_status: true, + is_starred: true, + type: true, + bookmark: { select: { target_url: true, title: true } }, + sr_user_bookmark_tag: { + where: { user_id: userId, is_deleted: false }, + orderBy: { id: 'asc' }, + select: { tag_name: true, source: true } + } + } + }) + } + public async listUserBookmarks(userId: number, offset: number, limit: number, filter: string) { let where: any = { user_id: userId, deleted_at: null } let orderBy: any = { created_at: 'desc' } From 18748912319ef4f7e7cda938d1abb3f7d8003eec Mon Sep 17 00:00:00 2001 From: wulujia Date: Wed, 9 Sep 2026 12:25:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20per-user=20switches=20for=20?= =?UTF-8?q?Labs=20features=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Labs is a card on the settings page: features still being tried out, off by default, switch stored server-side per user. - sr_user_lab_feature (user_id, feature, enabled, created_at, updated_at); no row means off. Migration adds the table and its two indexes. - LabRepo: listByUser / isEnabled / upsert on the Postgres client. - LabService: registry is an overridable method (empty here so a self-hosted build sees no change); status active reads the switch, graduated is on for everyone, retired is off for everyone. assertUrlAllowed() throws LAB_FEATURE_DISABLED when a URL needs a feature the user has not turned on; gatedFeatureForUrl() is the hook a hosted backend overrides to say which URLs are gated. - LAB_FEATURE_DISABLED (400) carries the feature's display name in the localized message (zh / en / es), so clients show it as-is. - GET /v1/user/labs returns { features: [{ key, status, enabled, enabled_at }] }. - POST /v1/user/setting/enable|disable with key "lab:" flips the switch; UserService takes LabService. - Regenerated router and DI. Claude-Session: https://claude.ai/code/session_01Xg119Pyy2B2UQQddrxw5Dv Co-authored-by: Claude Fable 5.1 --- prisma/hyperdrive.prisma | 14 ++ .../migration.sql | 15 ++ src/const/err.ts | 14 +- src/di/generated/dependency.ts | 16 +- src/di/generated/readerRouter.ts | 4 + src/domain/lab.ts | 100 +++++++++++ src/domain/user.ts | 10 +- src/handler/http/userController.ts | 13 +- src/infra/repository/dbLab.ts | 38 +++++ test/domain/labService.test.ts | 157 ++++++++++++++++++ test/domain/userSettingLab.test.ts | 33 ++++ 11 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260908000000_add_user_lab_feature/migration.sql create mode 100644 src/domain/lab.ts create mode 100644 src/infra/repository/dbLab.ts create mode 100644 test/domain/labService.test.ts create mode 100644 test/domain/userSettingLab.test.ts diff --git a/prisma/hyperdrive.prisma b/prisma/hyperdrive.prisma index b102815..6b885c3 100644 --- a/prisma/hyperdrive.prisma +++ b/prisma/hyperdrive.prisma @@ -332,3 +332,17 @@ model sr_user_bookmark_overview { @@index([bookmark_id, user_id]) } + +// Per-user switches for Labs features. One row per (user, feature); no row means off. +// created_at = first time the user touched the switch, updated_at = last change. +model sr_user_lab_feature { + id Int @id @default(autoincrement()) + user_id Int + feature String + enabled Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@unique([user_id, feature]) + @@index([feature, enabled]) +} diff --git a/prisma/migrations/20260908000000_add_user_lab_feature/migration.sql b/prisma/migrations/20260908000000_add_user_lab_feature/migration.sql new file mode 100644 index 0000000..6bcc2e6 --- /dev/null +++ b/prisma/migrations/20260908000000_add_user_lab_feature/migration.sql @@ -0,0 +1,15 @@ +-- sr_user_lab_feature: per-user switches for Labs features (no row = off) +CREATE TABLE "sr_user_lab_feature" ( + "id" SERIAL NOT NULL, + "user_id" INTEGER NOT NULL, + "feature" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sr_user_lab_feature_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "sr_user_lab_feature_user_id_feature_key" ON "sr_user_lab_feature"("user_id", "feature"); + +CREATE INDEX "sr_user_lab_feature_feature_enabled_idx" ON "sr_user_lab_feature"("feature", "enabled"); diff --git a/src/const/err.ts b/src/const/err.ts index 6df21a7..3d89851 100644 --- a/src/const/err.ts +++ b/src/const/err.ts @@ -72,7 +72,8 @@ export enum ErrorName { IMPORT_OTHER_TIMEOUT = 'IMPORT_OTHER_TIMEOUT', BOOKMARK_OVERVIEW_CONTENT_ERROR = 'BOOKMARK_OVERVIEW_CONTENT_ERROR', SYNC_TABLE_RULE_ERROR = 'SYNC_TABLE_RULE_ERROR', - SYNC_TABLE_TAG_NAME_ERROR = 'SYNC_TABLE_TAG_NAME_ERROR' + SYNC_TABLE_TAG_NAME_ERROR = 'SYNC_TABLE_TAG_NAME_ERROR', + LAB_FEATURE_DISABLED = 'LAB_FEATURE_DISABLED' } const translations: { [key in Language]: Partial> } = { @@ -91,6 +92,7 @@ const translations: { [key in Language]: Partial> } = [ErrorName.TRASH_REVERT_BOOKMARK_FAIL]: '移出垃圾篓失败', [ErrorName.BOOKMARK_NOT_FOUND]: '书签未找到', [ErrorName.BLOCK_TARGET_URL]: '目标网址被阻止', + [ErrorName.LAB_FEATURE_DISABLED]: '{feature}还在实验室里,请到设置页打开后再保存', [ErrorName.PROHIBITED_CONTENT]: '处理失败:内容被禁止', [ErrorName.SHARE_CONTENT_NOT_SUPPORTED]: '该内容类型不支持分享', [ErrorName.CREATE_BOOKMARK_FAIL]: '创建书签失败', @@ -139,6 +141,7 @@ const translations: { [key in Language]: Partial> } = [ErrorName.TRASH_REVERT_BOOKMARK_FAIL]: 'Revert bookmark failed', [ErrorName.BOOKMARK_NOT_FOUND]: 'Bookmark not found', [ErrorName.BLOCK_TARGET_URL]: 'Blocked target url', + [ErrorName.LAB_FEATURE_DISABLED]: '{feature} are still in Labs. Turn it on in Settings, then save again', [ErrorName.PROHIBITED_CONTENT]: 'Processing failed: prohibited content', [ErrorName.SHARE_CONTENT_NOT_SUPPORTED]: 'This type of content cannot be shared', [ErrorName.CREATE_BOOKMARK_FAIL]: 'Create bookmark failed', @@ -198,6 +201,7 @@ const translations: { [key in Language]: Partial> } = [ErrorName.SERVER_ERROR]: 'Error interno del servidor', [ErrorName.GOOGLE_SSO_ERROR]: 'Error de Google SSO', [ErrorName.PROHIBITED_CONTENT]: 'Error de procesamiento: contenido prohibido', + [ErrorName.LAB_FEATURE_DISABLED]: '{feature} todavía están en el Laboratorio. Actívalo en Ajustes y vuelve a guardar', [ErrorName.SHARE_CONTENT_NOT_SUPPORTED]: 'Este tipo de contenido no se puede compartir' } } @@ -292,3 +296,11 @@ export const ImportOtherTimeoutError = (): MultiLangError => NewError(ErrorName. export const BookmarkOverviewContentError = (): MultiLangError => NewError(ErrorName.BOOKMARK_OVERVIEW_CONTENT_ERROR, 500) export const SyncTableRuleError = (): MultiLangError => NewError(ErrorName.SYNC_TABLE_RULE_ERROR, 400) export const SyncTableTagNameError = (): MultiLangError => NewError(ErrorName.SYNC_TABLE_TAG_NAME_ERROR, 400) +/** Message carries the feature's display name so clients can show it as-is. */ +export const LabFeatureDisabledError = (featureName: { [lang in Language]?: string }): MultiLangError => { + const messages = loadErrorMessages(ErrorName.LAB_FEATURE_DISABLED) + for (const lang of Object.keys(messages) as Language[]) { + messages[lang] = (messages[lang] || '').replace('{feature}', featureName[lang] || featureName.en || '') + } + return new MultiLangError(ErrorName.LAB_FEATURE_DISABLED, 400, messages) +} diff --git a/src/di/generated/dependency.ts b/src/di/generated/dependency.ts index a4c2b27..d310edf 100644 --- a/src/di/generated/dependency.ts +++ b/src/di/generated/dependency.ts @@ -19,7 +19,9 @@ import { VectorizeRepo } from '../../infra/repository/dbVectorize' import { MarkRepo } from '../../infra/repository/dbMark' import { UserRepo } from '../../infra/repository/dbUser' import { NotificationMessage } from '../../infra/message/notification' +import { LabRepo } from '../../infra/repository/dbLab' import { ReportRepo } from '../../infra/repository/dbReport' +import { LabService } from '../../domain/lab' import { BookmarkService } from '../../domain/bookmark' import { TagService } from '../../domain/tag' import { MarkService } from '../../domain/mark' @@ -88,6 +90,10 @@ container.register(ImportService, { ) }) +container.register(LabService, { + useFactory: container => new LabService(container.resolve(LabRepo)) +}) + container.register(MarkService, { useFactory: container => new MarkService(container.resolve(BookmarkRepo), container.resolve(MarkRepo), container.resolve(UserRepo)) }) @@ -117,7 +123,8 @@ container.register(UserService, { new UserService( container.resolve(UserRepo), lazy(() => container.resolve(BucketClient)), - container.resolve(ReportRepo) + container.resolve(ReportRepo), + container.resolve(LabService) ) }) @@ -204,6 +211,10 @@ container.register(BookmarkSearchRepo, { useFactory: container => new BookmarkSearchRepo(lazy(() => container.resolve(PRISIMA_FULLTEXT_CLIENT))) }) +container.register(LabRepo, { + useFactory: container => new LabRepo(lazy(() => container.resolve(PRISIMA_HYPERDRIVE_CLIENT))) +}) + container.register(MarkRepo, { useFactory: container => new MarkRepo( @@ -285,7 +296,8 @@ container.register(TagController, { }) container.register(UserController, { - useFactory: container => new UserController(container.resolve(UserService), container.resolve(BookmarkService), container.resolve(NotificationService)) + useFactory: container => + new UserController(container.resolve(UserService), container.resolve(BookmarkService), container.resolve(NotificationService), container.resolve(LabService)) }) container.register(DatabaseRegistry, { diff --git a/src/di/generated/readerRouter.ts b/src/di/generated/readerRouter.ts index e771316..57df431 100644 --- a/src/di/generated/readerRouter.ts +++ b/src/di/generated/readerRouter.ts @@ -245,6 +245,10 @@ export function getRouter(container: Container) { const controller = container.resolve(UserController) return await controller.handleDisableUserSettingRequest(ctx, req) }) + router.get('/v1/user/labs', async (req: Request, ctx: ContextManager) => { + const controller = container.resolve(UserController) + return await controller.handleUserLabsRequest(ctx, req) + }) router.get('/v1/user/userinfo', async (req: Request, ctx: ContextManager) => { const controller = container.resolve(UserController) return await controller.handleUserInfoRequest(ctx, req) diff --git a/src/domain/lab.ts b/src/domain/lab.ts new file mode 100644 index 0000000..036ce0d --- /dev/null +++ b/src/domain/lab.ts @@ -0,0 +1,100 @@ +import { inject, injectable } from '../decorators/di' +import { LabRepo } from '../infra/repository/dbLab' +import { ErrorName, ErrorParam, LabFeatureDisabledError } from '../const/err' +import { MultiLangError, Language } from '../utils/multiLangError' +import { ContextManager } from '../utils/context' + +export type LabFeatureStatus = 'active' | 'graduated' | 'retired' + +export interface LabFeatureDef { + status: LabFeatureStatus + /** Display name per language, spliced into the "still in Labs" error message */ + name: { [lang in Language]?: string } + /** ISO date the feature left Labs; the settings page hides graduated rows after a while */ + graduatedAt?: string +} + +export interface LabFeatureItem { + key: string + status: Exclude + enabled: boolean + enabled_at: string | null +} + +/** + * Labs: per-user switches for features that are still being tried out. + * + * The registry is empty in the open-source build. A hosted backend subclasses this + * service and overrides `features()` (and `gatedFeatureForUrl()` to block saves). + */ +@injectable() +export class LabService { + constructor(@inject(LabRepo) private labRepo: LabRepo) {} + + /** Feature registry. Keys are what clients send as `lab:`. */ + protected features(): Record { + return {} + } + + /** Which Labs feature (if any) a save of this URL needs. null = not gated. */ + protected gatedFeatureForUrl(_url: string): string | null { + return null + } + + /** Rows for the settings page: active and graduated only. */ + public async listForUser(userId: number): Promise { + const defs = this.features() + const keys = Object.keys(defs).filter(key => defs[key].status !== 'retired') + if (keys.length === 0) return [] + + const rows = await this.labRepo.listByUser(userId) + const byKey = new Map(rows.map(row => [row.feature, row])) + + return keys.map(key => { + const def = defs[key] + const row = byKey.get(key) + const enabled = def.status === 'graduated' ? true : (row?.enabled ?? false) + return { + key, + status: def.status as Exclude, + enabled, + enabled_at: row?.enabled ? row.updated_at.toISOString() : null + } + }) + } + + /** Unknown or retired keys are rejected; graduated ones are written but never read. */ + public async setEnabled(userId: number, key: string, enabled: boolean): Promise { + const def = this.features()[key] + if (!def || def.status === 'retired') throw ErrorParam() + await this.labRepo.upsert(userId, key, enabled) + } + + public async isEnabled(userId: number, key: string): Promise { + const def = this.features()[key] + if (!def || def.status === 'retired') return false + if (def.status === 'graduated') return true + return this.labRepo.isEnabled(userId, key) + } + + /** Throws LAB_FEATURE_DISABLED when the URL needs a Labs feature the user has not turned on. */ + public async assertUrlAllowed(ctx: ContextManager, url: string): Promise { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return // invalid URLs are reported by the existing save logic + } + const key = this.gatedFeatureForUrl(parsed.toString()) + if (!key) return + if (!(await this.isEnabled(ctx.getUserId(), key))) throw this.disabledError(key) + } + + public disabledError(key: string): MultiLangError { + return LabFeatureDisabledError(this.features()[key]?.name ?? { en: key }) + } + + public static isLabDisabledError(e: unknown): boolean { + return e instanceof MultiLangError && e.name === ErrorName.LAB_FEATURE_DISABLED + } +} diff --git a/src/domain/user.ts b/src/domain/user.ts index f0c2afe..0c49c79 100644 --- a/src/domain/user.ts +++ b/src/domain/user.ts @@ -12,6 +12,7 @@ import type { LazyInstance } from '../decorators/lazy' import { UserRepo } from '../infra/repository/dbUser' import { BucketClient } from '../infra/repository/bucketClient' import { ReportRepo } from '../infra/repository/dbReport' +import { LabService } from './lab' export interface userShareCollectInfo { show_name: string @@ -107,7 +108,8 @@ export class UserService { constructor( @inject(UserRepo) private userRepo: UserRepo, @inject(BucketClient) private bucketClient: LazyInstance, - @inject(ReportRepo) private reportRepo: ReportRepo + @inject(ReportRepo) private reportRepo: ReportRepo, + @inject(LabService) private labService: LabService ) {} /** @@ -335,6 +337,12 @@ export class UserService { public async enableUserSetting(ctx: ContextManager, setting: string, enable: boolean): Promise { // const user = await this.userRepo.getInfoByUserId(ctx.getUserId()) + // Labs switches share this endpoint: key "lab:" + if (setting.startsWith('lab:')) { + await this.labService.setEnabled(ctx.getUserId(), setting.slice(4), enable) + return 'ok' + } + if (setting === 'mail_collect' && !enable) { await this.userRepo.unbindPlatform(ctx.getUserId(), platformBindType.EMAIL) } diff --git a/src/handler/http/userController.ts b/src/handler/http/userController.ts index 399b542..7dc82e9 100644 --- a/src/handler/http/userController.ts +++ b/src/handler/http/userController.ts @@ -10,13 +10,15 @@ import { inject } from '../../decorators/di' import { BookmarkService } from '../../domain/bookmark' import { NotificationService } from '../../domain/notification' import { UserService } from '../../domain/user' +import { LabService } from '../../domain/lab' @Controller('/v1/user') export class UserController { constructor( @inject(UserService) private userService: UserService, @inject(BookmarkService) private bookmarkService: BookmarkService, - @inject(NotificationService) private notificationService: NotificationService + @inject(NotificationService) private notificationService: NotificationService, + @inject(LabService) private labService: LabService ) {} /** @@ -116,6 +118,15 @@ export class UserController { return Successed(res) } + /** + * 实验室功能列表(含当前用户的开关状态) + */ + @Get('/labs') + public async handleUserLabsRequest(ctx: ContextManager, request: Request) { + const features = await this.labService.listForUser(ctx.getUserId()) + return Successed({ features }) + } + /** * 获取用户信息 */ diff --git a/src/infra/repository/dbLab.ts b/src/infra/repository/dbLab.ts new file mode 100644 index 0000000..5416603 --- /dev/null +++ b/src/infra/repository/dbLab.ts @@ -0,0 +1,38 @@ +import { inject, singleton } from '../../decorators/di' +import { PRISIMA_HYPERDRIVE_CLIENT } from '../../const/symbol' +import type { LazyInstance } from '../../decorators/lazy' +import { PrismaClient as HyperdrivePrismaClient } from '@prisma/hyperdrive-client' + +export interface userLabFeaturePO { + id: number + user_id: number + feature: string + enabled: boolean + created_at: Date + updated_at: Date +} + +@singleton() +export class LabRepo { + constructor(@inject(PRISIMA_HYPERDRIVE_CLIENT) private prismaPg: LazyInstance) {} + + public async listByUser(userId: number): Promise { + return this.prismaPg().sr_user_lab_feature.findMany({ where: { user_id: userId } }) + } + + public async isEnabled(userId: number, feature: string): Promise { + const row = await this.prismaPg().sr_user_lab_feature.findUnique({ + where: { user_id_feature: { user_id: userId, feature } }, + select: { enabled: true } + }) + return row?.enabled ?? false + } + + public async upsert(userId: number, feature: string, enabled: boolean): Promise { + return this.prismaPg().sr_user_lab_feature.upsert({ + where: { user_id_feature: { user_id: userId, feature } }, + create: { user_id: userId, feature, enabled }, + update: { enabled } + }) + } +} diff --git a/test/domain/labService.test.ts b/test/domain/labService.test.ts new file mode 100644 index 0000000..3a158f2 --- /dev/null +++ b/test/domain/labService.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { LabService, type LabFeatureDef } from '../../src/domain/lab' +import { ErrorName } from '../../src/const/err' +import { setGlobalLanguage } from '../../src/utils/multiLangError' + +const userId = 7 + +function createRepo() { + return { + listByUser: vi.fn().mockResolvedValue([]), + isEnabled: vi.fn().mockResolvedValue(false), + upsert: vi.fn().mockResolvedValue(undefined) + } +} + +class TestLabService extends LabService { + constructor( + repo: any, + private defs: Record, + private gated: Record = {} + ) { + super(repo) + } + protected features() { + return this.defs + } + protected gatedFeatureForUrl(url: string) { + return Object.entries(this.gated).find(([host]) => new URL(url).hostname === host)?.[1] ?? null + } +} + +const defs: Record = { + youtube: { status: 'active', name: { zh: 'YouTube 视频', en: 'YouTube videos' } }, + pdf: { status: 'graduated', name: { en: 'PDF files' }, graduatedAt: '2026-09-01' }, + podcast: { status: 'retired', name: { en: 'Podcasts' } } +} + +describe('LabService with an empty registry (open-source default)', () => { + test('lists nothing, blocks nothing, rejects every key', async () => { + const repo = createRepo() + const service = new LabService(repo as any) + + expect(await service.listForUser(userId)).toEqual([]) + expect(repo.listByUser).not.toHaveBeenCalled() + expect(await service.isEnabled(userId, 'youtube')).toBe(false) + await expect(service.setEnabled(userId, 'youtube', true)).rejects.toMatchObject({ name: ErrorName.ERROR_PARAM }) + await expect(service.assertUrlAllowed({ getUserId: () => userId } as any, 'https://www.youtube.com/watch?v=abc')).resolves.toBeUndefined() + }) +}) + +describe('LabService.isEnabled', () => { + test('active reads the user row, missing row means off', async () => { + const repo = createRepo() + const service = new TestLabService(repo, defs) + + expect(await service.isEnabled(userId, 'youtube')).toBe(false) + expect(repo.isEnabled).toHaveBeenCalledWith(userId, 'youtube') + + repo.isEnabled.mockResolvedValue(true) + expect(await service.isEnabled(userId, 'youtube')).toBe(true) + }) + + test('graduated is on for everyone, retired and unknown are off, without touching the table', async () => { + const repo = createRepo() + const service = new TestLabService(repo, defs) + + expect(await service.isEnabled(userId, 'pdf')).toBe(true) + expect(await service.isEnabled(userId, 'podcast')).toBe(false) + expect(await service.isEnabled(userId, 'nope')).toBe(false) + expect(repo.isEnabled).not.toHaveBeenCalled() + }) +}) + +describe('LabService.setEnabled', () => { + test('writes active and graduated keys, rejects retired and unknown keys', async () => { + const repo = createRepo() + const service = new TestLabService(repo, defs) + + await service.setEnabled(userId, 'youtube', true) + expect(repo.upsert).toHaveBeenCalledWith(userId, 'youtube', true) + + await service.setEnabled(userId, 'pdf', false) + expect(repo.upsert).toHaveBeenCalledWith(userId, 'pdf', false) + + await expect(service.setEnabled(userId, 'podcast', true)).rejects.toMatchObject({ name: ErrorName.ERROR_PARAM }) + await expect(service.setEnabled(userId, 'nope', true)).rejects.toMatchObject({ name: ErrorName.ERROR_PARAM }) + expect(repo.upsert).toHaveBeenCalledTimes(2) + }) +}) + +describe('LabService.listForUser', () => { + test('returns active and graduated rows with the user state, hides retired', async () => { + const repo = createRepo() + const enabledAt = new Date('2026-09-08T01:02:03.000Z') + repo.listByUser.mockResolvedValue([ + { feature: 'youtube', enabled: true, created_at: enabledAt, updated_at: enabledAt }, + { feature: 'podcast', enabled: true, created_at: enabledAt, updated_at: enabledAt } + ]) + const service = new TestLabService(repo, defs) + + expect(await service.listForUser(userId)).toEqual([ + { key: 'youtube', status: 'active', enabled: true, enabled_at: '2026-09-08T01:02:03.000Z' }, + { key: 'pdf', status: 'graduated', enabled: true, enabled_at: null } + ]) + }) + + test('a switched-off row reports enabled=false with no enabled_at', async () => { + const repo = createRepo() + const at = new Date('2026-09-08T01:02:03.000Z') + repo.listByUser.mockResolvedValue([{ feature: 'youtube', enabled: false, created_at: at, updated_at: at }]) + const service = new TestLabService(repo, defs) + + const [youtube] = await service.listForUser(userId) + expect(youtube).toEqual({ key: 'youtube', status: 'active', enabled: false, enabled_at: null }) + }) +}) + +describe('LabService.assertUrlAllowed', () => { + const ctx = { getUserId: () => userId } as any + const gated = { 'www.youtube.com': 'youtube', 'files.example.com': 'pdf', 'pod.example.com': 'podcast' } + + beforeEach(() => setGlobalLanguage('en')) + + test('throws LAB_FEATURE_DISABLED with the feature name when the switch is off', async () => { + const service = new TestLabService(createRepo(), defs, gated) + + const err = await service.assertUrlAllowed(ctx, 'https://www.youtube.com/watch?v=abc').catch(e => e) + expect(err.name).toBe(ErrorName.LAB_FEATURE_DISABLED) + expect(err.errCode).toBe(400) + expect(err.getMessage).toBe('YouTube videos are still in Labs. Turn it on in Settings, then save again') + expect(LabService.isLabDisabledError(err)).toBe(true) + + setGlobalLanguage('zh') + expect(err.getMessage).toBe('YouTube 视频还在实验室里,请到设置页打开后再保存') + }) + + test('passes when the switch is on, when the feature graduated, and for ungated or invalid URLs', async () => { + const repo = createRepo() + repo.isEnabled.mockResolvedValue(true) + const service = new TestLabService(repo, defs, gated) + + await expect(service.assertUrlAllowed(ctx, 'https://www.youtube.com/watch?v=abc')).resolves.toBeUndefined() + await expect(service.assertUrlAllowed(ctx, 'https://files.example.com/a.pdf')).resolves.toBeUndefined() + await expect(service.assertUrlAllowed(ctx, 'https://example.com/post')).resolves.toBeUndefined() + await expect(service.assertUrlAllowed(ctx, 'not a url')).resolves.toBeUndefined() + }) + + test('a retired feature blocks the URL for everyone', async () => { + const service = new TestLabService(createRepo(), defs, gated) + await expect(service.assertUrlAllowed(ctx, 'https://pod.example.com/ep1')).rejects.toMatchObject({ name: ErrorName.LAB_FEATURE_DISABLED }) + }) + + test('isLabDisabledError ignores other errors', () => { + expect(LabService.isLabDisabledError(new Error('x'))).toBe(false) + expect(LabService.isLabDisabledError(null)).toBe(false) + }) +}) diff --git a/test/domain/userSettingLab.test.ts b/test/domain/userSettingLab.test.ts new file mode 100644 index 0000000..d35a0b0 --- /dev/null +++ b/test/domain/userSettingLab.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test, vi } from 'vitest' +import { UserService } from '../../src/domain/user' + +function createService() { + const labService = { setEnabled: vi.fn().mockResolvedValue(undefined) } + const userRepo = { unbindPlatform: vi.fn(), getInfoByUserId: vi.fn() } + const service = Object.create(UserService.prototype) as UserService + Object.assign(service, { labService, userRepo }) + return { service, labService, userRepo } +} + +const ctx = { getUserId: () => 7 } as never + +describe('UserService.enableUserSetting with a lab: key', () => { + test('enable forwards the feature key to LabService', async () => { + const { service, labService, userRepo } = createService() + expect(await service.enableUserSetting(ctx, 'lab:youtube', true)).toBe('ok') + expect(labService.setEnabled).toHaveBeenCalledWith(7, 'youtube', true) + expect(userRepo.unbindPlatform).not.toHaveBeenCalled() + }) + + test('disable forwards enable=false', async () => { + const { service, labService } = createService() + await service.enableUserSetting(ctx, 'lab:youtube', false) + expect(labService.setEnabled).toHaveBeenCalledWith(7, 'youtube', false) + }) + + test('other keys never reach LabService', async () => { + const { service, labService } = createService() + await service.enableUserSetting(ctx, 'mail_collect', false) + expect(labService.setEnabled).not.toHaveBeenCalled() + }) +})