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
4 changes: 4 additions & 0 deletions src/di/generated/readerRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions src/domain/mark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}))
}
}
96 changes: 95 additions & 1 deletion src/domain/orchestrator/sync.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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'
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'

Expand Down Expand Up @@ -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<T extends string, D = undefined, U = string> = {
userId: number
type: T
Expand All @@ -66,13 +84,22 @@ type TagSyncOperation<T extends string, D = undefined, U = string> = {
data: D
}

type CommentSyncOperation<T extends string, D = undefined, U = string> = {
userId: number
type: T
commentUuid: U
data: D
}

export type OrderedSyncOperation =
| TagSyncOperation<'create_tag', CreateTagData, string>
| SyncOperation<'create_bookmark', CreateBookmarkData, string>
| SyncOperation<'update_bookmark', UpdateBookmarkData, string>
| 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
Expand Down Expand Up @@ -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()
}
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/handler/http/markController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 4 additions & 0 deletions src/infra/repository/dbBookmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })
}
Expand Down
9 changes: 9 additions & 0 deletions src/infra/repository/dbMark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,13 @@ export class MarkRepo {
take: size
})
}

async getDistinctUserIdsByUserBookmarkUuid(userBookmarkUuid: string): Promise<number[]> {
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)
}
}
134 changes: 132 additions & 2 deletions src/infra/repository/dbSyncBatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HyperdrivePrismaClient<Prisma.PrismaClientOptions>, '$connect' | '$disconnect' | '$on' | '$transaction' | '$extends'>
export type executeFunction = (tx: prismaTx, operation: OrderedSyncOperation) => Promise<{ bookmarkId: number; targetUrl: string; userId: number } | null | void>
Expand All @@ -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 => {
Expand Down Expand Up @@ -219,4 +232,121 @@ export class DBSyncBatchOperation {
data: { deleted_at: new Date() }
})
}

/** create comment */
public async executeCreateComment(tx: prismaTx, operation: OrderedSyncOperation): Promise<void> {
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<void> {
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() }
})
}
}
Loading