diff --git a/src/di/generated/readerRouter.ts b/src/di/generated/readerRouter.ts index f0b2a6c..d5d4815 100644 --- a/src/di/generated/readerRouter.ts +++ b/src/di/generated/readerRouter.ts @@ -145,6 +145,10 @@ export function getRouter(container: Container) { const controller = container.resolve(MarkController) return await controller.getMarkList(ctx, req) }) + router.get('/v1/mark/users', async (req: Request, ctx: ContextManager) => { + const controller = container.resolve(MarkController) + return await controller.getMarkUsers(ctx, req) + }) router.all('/v1/mcp/*', async (req: Request, ctx: ContextManager) => { const controller = container.resolve(McpServerController) return await controller.handleMcpRequest(ctx, req) diff --git a/src/domain/mark.ts b/src/domain/mark.ts index 9af7531..85d526a 100644 --- a/src/domain/mark.ts +++ b/src/domain/mark.ts @@ -409,4 +409,25 @@ export class MarkService { return res } + + public async getMarkUsersByUserBookmarkUuid(ctx: ContextManager, userBookmarkUuid: string): Promise<{ uuid: string; nick_name: string; avatar: string }[]> { + const userBookmark = await this.bookmarkRepo.getUserBookmarkByUuid(userBookmarkUuid) + if (!userBookmark) throw BookmarkNotFoundError() + + // check permission + if (userBookmark.user_id !== ctx.getUserId()) { + const share = await this.bookmarkRepo.getBookmarkShareByBookmarkId(userBookmark.bookmark_id, userBookmark.user_id) + if (!share || !share.is_enable) throw ShareActionNotAllowedError() + } + + const userIds = await this.markRepo.getDistinctUserIdsByUserBookmarkUuid(userBookmarkUuid) + if (userIds.length === 0) return [] + + const users = await this.userRepo.getUserInfoList(userIds) + return users.map(u => ({ + uuid: u.uuid, + nick_name: u.name, + avatar: u.picture + })) + } } diff --git a/src/domain/orchestrator/sync.ts b/src/domain/orchestrator/sync.ts index 5e85231..a1d0a97 100644 --- a/src/domain/orchestrator/sync.ts +++ b/src/domain/orchestrator/sync.ts @@ -1,4 +1,4 @@ -import { ErrorParam, SyncTableRuleError, SyncTableTagNameError, UserNotFoundError } from '../../const/err' +import { ErrorMarkTypeError, ErrorParam, SyncTableRuleError, SyncTableTagNameError, UserNotFoundError } from '../../const/err' import { inject, injectable } from '../../decorators/di' import { ContextManager } from '../../utils/context' import { UserService } from '../user' @@ -6,6 +6,7 @@ import { SignJWT } from 'jose' import { DBSyncBatchOperation } from '../../infra/repository/dbSyncBatch' import { QueueClient, queueRetryParseMessage, callbackType } from '../../infra/queue/queueClient' import { parserType, URLPolicie } from '../../utils/urlPolicie' +import { markType } from '../../infra/repository/dbMark' export type SyncExecOperation = 'PUT' | 'PATCH' | 'DELETE' @@ -52,6 +53,23 @@ export interface UpdateShareData { isEnable: boolean } +export interface CreateCommentData { + userBookmarkUuid: string + type: number + source: string + comment: string + rootUuid: string + parentUuid: string + approxSource: string + content: string + sourceType: string + sourceId: string +} + +export interface DeleteCommentData { + isDeleted: boolean +} + type SyncOperation = { userId: number type: T @@ -66,6 +84,13 @@ type TagSyncOperation = { data: D } +type CommentSyncOperation = { + userId: number + type: T + commentUuid: U + data: D +} + export type OrderedSyncOperation = | TagSyncOperation<'create_tag', CreateTagData, string> | SyncOperation<'create_bookmark', CreateBookmarkData, string> @@ -73,6 +98,8 @@ export type OrderedSyncOperation = | SyncOperation<'update_tags', UpdateTagsData, string> | SyncOperation<'update_share', UpdateShareData, string> | SyncOperation<'delete_bookmark', undefined, string> + | CommentSyncOperation<'create_comment', CreateCommentData, string> + | CommentSyncOperation<'delete_comment', DeleteCommentData, string> export interface SyncChangeUserBookmark { uuid: string @@ -140,6 +167,8 @@ export class SyncOrchestrator { this.processUserBookmarkChange(change, userId, orderedOperations) } else if (change.table === 'sr_user_tag' && change.op === 'PUT') { this.processUserTagChange(change, userId, orderedOperations) + } else if (change.table === 'sr_bookmark_comment') { + this.processUserBookmarkCommentChange(change, userId, orderedOperations) } else { throw SyncTableRuleError() } @@ -151,6 +180,71 @@ export class SyncOrchestrator { } } + public processUserBookmarkCommentChange(change: SyncChangeItem, userId: number, operations: OrderedSyncOperation[]) { + if (!change.data) return + + // soft delete comment + if (change.op === 'PATCH' && change.data && change.data.hasOwnProperty('is_deleted')) { + const isDeleted = change.data.is_deleted === 'true' || change.data.is_deleted === '1' + if (!isDeleted) return + + operations.push({ + type: 'delete_comment', + commentUuid: change.id, + userId, + data: { + isDeleted: true + } + }) + return + } + + // create comment + if (change.op === 'PUT') { + const userBookmarkUuid = change.data['user_bookmark_uuid'] || '' + if (!userBookmarkUuid) throw ErrorParam() + + const type = parseInt(change.data['type'] || '0') + if (![markType.LINE, markType.COMMENT, markType.REPLY, markType.ORIGIN_LINE, markType.ORIGIN_COMMENT].includes(type)) { + throw ErrorMarkTypeError() + } + + // 从 metadata 中提取 root_id、parent_id、source_id + let rootUuid = '' + let parentUuid = '' + let sourceId = '' + if (change.data['metadata']) { + try { + const metadata = JSON.parse(change.data['metadata']) as { root_id?: string; parent_id?: string; source_id?: string } + rootUuid = metadata.root_id || '' + parentUuid = metadata.parent_id || '' + sourceId = metadata.source_id || '' + } catch { + // metadata parse failure + } + } + + operations.push({ + type: 'create_comment', + commentUuid: change.id, + userId, + data: { + userBookmarkUuid, + type, + source: change.data['source'] || '[]', + comment: change.data['comment'] || '', + rootUuid, + parentUuid, + approxSource: change.data['approx_source'] || '', + content: change.data['content'] || '', + sourceType: sourceId ? 'bookmark' : '', + sourceId + } + }) + return + } + } + /** user bookmark change */ public processUserBookmarkChange(change: SyncChangeItem, userId: number, operations: OrderedSyncOperation[]) { if (!change.data) return diff --git a/src/handler/http/markController.ts b/src/handler/http/markController.ts index 4afccdd..8ddf70c 100644 --- a/src/handler/http/markController.ts +++ b/src/handler/http/markController.ts @@ -72,4 +72,13 @@ export class MarkController { const listResult = await this.markService.getMarkList(ctx, page, size) return Successed(listResult) } + + @Get('/users') + public async getMarkUsers(ctx: ContextManager, request: Request) { + const req = await RequestUtils.query<{ user_bookmark_uuid: string }>(request) + if (!req || !req.user_bookmark_uuid) return Failed(ErrorParam()) + + const users = await this.markService.getMarkUsersByUserBookmarkUuid(ctx, req.user_bookmark_uuid) + return Successed(users) + } } diff --git a/src/infra/repository/dbBookmark.ts b/src/infra/repository/dbBookmark.ts index d2401de..1fe64ad 100644 --- a/src/infra/repository/dbBookmark.ts +++ b/src/infra/repository/dbBookmark.ts @@ -214,6 +214,10 @@ export class BookmarkRepo { return await this.prismaPg().sr_user_bookmark.findFirst({ where: { uuid: uid, user_id: userId }, include: { bookmark: true } }) } + public async getUserBookmarkByUuid(uuid: string) { + return await this.prismaPg().sr_user_bookmark.findFirst({ where: { uuid } }) + } + public async getUserBookmarkByUserBmId(userBmId: number) { return await this.prismaPg().sr_user_bookmark.findFirst({ where: { id: userBmId }, include: { bookmark: true } }) } diff --git a/src/infra/repository/dbMark.ts b/src/infra/repository/dbMark.ts index 81ca83e..692b554 100644 --- a/src/infra/repository/dbMark.ts +++ b/src/infra/repository/dbMark.ts @@ -193,4 +193,13 @@ export class MarkRepo { take: size }) } + + async getDistinctUserIdsByUserBookmarkUuid(userBookmarkUuid: string): Promise { + const comments = await this.prismaPg().sr_bookmark_comment.findMany({ + where: { user_bookmark_uuid: userBookmarkUuid, is_deleted: false }, + select: { user_id: true }, + distinct: ['user_id'] + }) + return comments.map(c => c.user_id) + } } diff --git a/src/infra/repository/dbSyncBatch.ts b/src/infra/repository/dbSyncBatch.ts index af6491e..7d7c550 100644 --- a/src/infra/repository/dbSyncBatch.ts +++ b/src/infra/repository/dbSyncBatch.ts @@ -2,7 +2,18 @@ import { PRISIMA_HYPERDRIVE_CLIENT } from '../../const/symbol' import { inject, injectable } from '../../decorators/di' import { LazyInstance } from '../../decorators/lazy' import { Prisma, PrismaClient as HyperdrivePrismaClient } from '@prisma/hyperdrive-client' -import { OrderedSyncOperation, CreateTagData, CreateBookmarkData, UpdateBookmarkData, UpdateTagsData, UpdateShareData } from '../../domain/orchestrator/sync' +import { + OrderedSyncOperation, + CreateTagData, + CreateBookmarkData, + UpdateBookmarkData, + UpdateTagsData, + UpdateShareData, + CreateCommentData, + DeleteCommentData +} from '../../domain/orchestrator/sync' +import { CommentTooLongError, ErrorMarkTypeError, MarkLineTooLongError, ShareActionNotAllowedError } from '../../const/err' +import { markType } from './dbMark' export type prismaTx = Omit, '$connect' | '$disconnect' | '$on' | '$transaction' | '$extends'> export type executeFunction = (tx: prismaTx, operation: OrderedSyncOperation) => Promise<{ bookmarkId: number; targetUrl: string; userId: number } | null | void> @@ -22,7 +33,9 @@ export class DBSyncBatchOperation { update_bookmark: this.executeUpdateBookmark, update_tags: this.executeUpdateTags, update_share: this.executeUpdateShare, - delete_bookmark: this.executeDeleteBookmark + delete_bookmark: this.executeDeleteBookmark, + create_comment: this.executeCreateComment, + delete_comment: this.executeDeleteComment } await this.prismaHyperdrive().$transaction(async tx => { @@ -219,4 +232,121 @@ export class DBSyncBatchOperation { data: { deleted_at: new Date() } }) } + + /** create comment */ + public async executeCreateComment(tx: prismaTx, operation: OrderedSyncOperation): Promise { + if (operation.type !== 'create_comment') return + + const { userBookmarkUuid, type, source, comment, rootUuid, parentUuid, approxSource, content, sourceType, sourceId } = operation.data as CreateCommentData + + if (type === markType.LINE && comment) throw ErrorMarkTypeError() + if (type === markType.COMMENT && (!comment || comment.length < 1)) throw ErrorMarkTypeError() + if (type === markType.REPLY && (!comment || comment.length < 1)) throw ErrorMarkTypeError() + if ([markType.ORIGIN_COMMENT, markType.ORIGIN_LINE].includes(type) && !approxSource) throw ErrorMarkTypeError() + if (comment && comment.length > 1500) throw CommentTooLongError() + + // 校验 source 长度(非回复类型) + if (type !== markType.REPLY) { + try { + const sourceItems = JSON.parse(source) as Array<{ type: string; start: number; end: number }> + if (Array.isArray(sourceItems)) { + let sourceLength = 0 + let sourceImageCount = 0 + sourceItems.forEach(item => { + if (item.type === 'image') sourceImageCount++ + else sourceLength += (item.end || 0) - (item.start || 0) + }) + if (sourceLength > 1000 || sourceImageCount > 3) throw MarkLineTooLongError() + } + } catch (e) { + if (e instanceof Error && (e.message.includes('MarkLine') || e.message.includes('MARK_LINE'))) throw e + } + } + + // 查找目标书签(sr_user_bookmark) + const userBookmark = await tx.sr_user_bookmark.findUnique({ + where: { uuid: userBookmarkUuid } + }) + if (!userBookmark) throw ShareActionNotAllowedError() + + // 权限校验:本人书签直接放行,非本人需检查是否开启分享 + if (userBookmark.user_id !== operation.userId) { + const share = await tx.sr_bookmark_share.findFirst({ + where: { bookmark_id: userBookmark.bookmark_id, user_id: userBookmark.user_id, is_enable: true } + }) + if (!share) throw ShareActionNotAllowedError() + } + + // 通过 UUID 解析 root_id 和 parent_id 对应的整数 ID + let rootId = 0 + let parentId = 0 + + if (rootUuid) { + const rootComment = await tx.sr_bookmark_comment.findUnique({ where: { uuid: rootUuid } }) + if (rootComment) rootId = rootComment.id + } + + if (type === markType.REPLY && parentUuid) { + const parentComment = await tx.sr_bookmark_comment.findUnique({ where: { uuid: parentUuid } }) + if (!parentComment) throw ShareActionNotAllowedError() + if (parentComment.bookmark_id !== userBookmark.id) throw ShareActionNotAllowedError() + if (parentComment.is_deleted) throw ShareActionNotAllowedError() + parentId = parentComment.id + if (parentComment.root_id > 0) rootId = parentComment.root_id + } + + const created = await tx.sr_bookmark_comment.create({ + data: { + uuid: operation.commentUuid, + user_id: operation.userId, + bookmark_id: userBookmark.id, + user_bookmark_uuid: userBookmarkUuid, + type, + source, + comment, + root_id: rootId, + parent_id: parentId, + approx_source: approxSource, + content, + source_type: sourceType, + source_id: sourceId, + is_deleted: false, + created_at: new Date(), + updated_at: new Date() + } + }) + + if ([markType.COMMENT, markType.ORIGIN_COMMENT].includes(type) && rootId === 0) { + await tx.sr_bookmark_comment.update({ + where: { id: created.id }, + data: { root_id: created.id, updated_at: new Date() } + }) + } + } + + /** soft delete comment */ + public async executeDeleteComment(tx: prismaTx, operation: OrderedSyncOperation): Promise { + if (operation.type !== 'delete_comment') return + + const { isDeleted } = operation.data as DeleteCommentData + + const commentRecord = await tx.sr_bookmark_comment.findUnique({ + where: { uuid: operation.commentUuid } + }) + if (!commentRecord) return + + // 权限校验:评论作者可删除自己的评论,书签拥有者可删除其书签下的任意评论 + if (commentRecord.user_id !== operation.userId) { + // bookmark_id 存的是 sr_user_bookmark.id,用 id 去查 + const userBookmark = await tx.sr_user_bookmark.findFirst({ + where: { id: commentRecord.bookmark_id, user_id: operation.userId } + }) + if (!userBookmark) throw ShareActionNotAllowedError() + } + + await tx.sr_bookmark_comment.update({ + where: { uuid: operation.commentUuid }, + data: { is_deleted: isDeleted, updated_at: new Date() } + }) + } }