add 2025-0124-

This commit is contained in:
ditiqi 2025-01-24 15:06:57 +08:00
parent bb2aa599ba
commit a5205884fe
17 changed files with 236 additions and 64 deletions

View File

@ -7,11 +7,12 @@ import {
RolePerms,
ResPerm,
ObjectType,
PostType,
} from '@nice/common';
import { MessageService } from '../message/message.service';
import { BaseService } from '../base/base.service';
import { DepartmentService } from '../department/department.service';
import { setPostRelation } from './utils';
import { setPostRelation, updatePostState } from './utils';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable()
@ -22,6 +23,12 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
) {
super(db, ObjectType.POST);
}
onModuleInit() {
EventBus.on('updatePostState', ({ id }) => {
console.log('updatePostState');
updatePostState(id);
});
}
async create(
args: Prisma.PostCreateArgs,
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
@ -36,6 +43,11 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
operation: CrudOperation.CREATED,
data: result,
});
if (args.data.authorId && args?.data?.type === PostType.POST_COMMENT) {
EventBus.emit('updatePostState', {
id: result?.id,
});
}
return result;
}
async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) {

View File

@ -1,4 +1,11 @@
import { db, Post, PostType, UserProfile, VisitType } from '@nice/common';
import {
db,
Post,
PostState,
PostType,
UserProfile,
VisitType,
} from '@nice/common';
export async function setPostRelation(params: {
data: Post;
@ -71,3 +78,55 @@ export function getClientIp(req: any): string {
return ip || '';
}
export async function updatePostState(id: string) {
const post = await db.post.findUnique({
where: {
id: id,
},
select: {
id: true,
state: true,
receivers: {
select: {
id: true,
},
},
},
});
if (post?.state === PostState.COMPLETED) {
return;
}
const postReceiverIds = post.receivers
.map((receiver) => receiver.id)
.filter(Boolean);
const receiverViews = await db.visit.count({
where: {
postId: id,
type: VisitType.READED,
visitorId: {
in: postReceiverIds,
},
},
});
if (receiverViews > 0 && post.state === PostState.PENDING) {
await db.post.update({
where: { id },
data: { state: PostState.PROCESSING },
});
}
const receiverComments = await db.post.count({
where: {
parentId: id,
type: PostType.POST_COMMENT,
authorId: {
in: postReceiverIds,
},
},
});
if (receiverComments > 0) {
await db.post.update({
where: { id },
data: { state: PostState.COMPLETED },
});
}
}

View File

@ -58,20 +58,26 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
}
}
}
if (postId && args.data.type === VisitType.READED) {
EventBus.emit('updateVisitCount', {
objectType: ObjectType.POST,
id: postId,
visitType: VisitType.READED,
});
}
if (postId && args.data.type === VisitType.LIKE) {
EventBus.emit('updateVisitCount', {
objectType: ObjectType.POST,
id: postId,
visitType: VisitType.LIKE,
});
if (postId) {
if (visitorId) {
EventBus.emit('updatePostState', {
id: postId,
});
}
if (args.data.type === VisitType.READED) {
EventBus.emit('updateVisitCount', {
objectType: ObjectType.POST,
id: postId,
visitType: VisitType.READED,
});
}
if (args.data.type === VisitType.LIKE) {
EventBus.emit('updateVisitCount', {
objectType: ObjectType.POST,
id: postId,
visitType: VisitType.LIKE,
});
}
}
return result;
}

View File

@ -3,7 +3,11 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import EventBus from '@server/utils/event-bus';
import { ObjectType, VisitType } from '@nice/common';
import { QueueJobType, updateVisitCountJobData } from '@server/queue/types';
import {
QueueJobType,
updatePostStateJobData,
updateVisitCountJobData,
} from '@server/queue/types';
@Injectable()
export class PostQueueService implements OnModuleInit {
@ -16,6 +20,10 @@ export class PostQueueService implements OnModuleInit {
this.addUpdateVisitCountJob({ id, type: visitType });
}
});
EventBus.on('updatePostState', ({ id }) => {
console.log('updatePostState');
this.addUpdatePostState({ id });
});
}
async addUpdateVisitCountJob(data: updateVisitCountJobData) {
this.logger.log(`update post view count ${data.id}`);
@ -23,4 +31,10 @@ export class PostQueueService implements OnModuleInit {
debounce: { id: data.id },
});
}
async addUpdatePostState(data: updatePostStateJobData) {
this.logger.log(`update post state ${data.id}`);
await this.generalQueue.add(QueueJobType.UPDATE_POST_STATE, data, {
debounce: { id: data.id },
});
}
}

View File

@ -1,4 +1,4 @@
import { db, VisitType } from '@nice/common';
import { db, PostState, PostType, VisitType } from '@nice/common';
export async function updatePostViewCount(id: string, type: VisitType) {
const totalViews = await db.visit.aggregate({
_sum: {
@ -29,22 +29,3 @@ export async function updatePostViewCount(id: string, type: VisitType) {
});
}
}
export async function updatePostLikeCount(id: string) {
const totalViews = await db.visit.aggregate({
_sum: {
views: true,
},
where: {
postId: id,
type: VisitType.LIKE,
},
});
await db.post.update({
where: {
id,
},
data: {
views: totalViews._sum.views || 0, // Use 0 if no visits exist
},
});
}

View File

@ -3,8 +3,12 @@ export enum QueueJobType {
UPDATE_STATS = 'update_stats',
FILE_PROCESS = 'file_process',
UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount',
UPDATE_POST_STATE = 'updatePostState',
}
export type updateVisitCountJobData = {
id: string;
type: VisitType;
};
export type updatePostStateJobData = {
id: string;
};

View File

@ -41,6 +41,9 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) {
await updatePostViewCount(job.data.id, job.data.type);
}
if (job.name === QueueJobType.UPDATE_POST_STATE) {
await updatePostViewCount(job.data.id, job.data.type);
}
} catch (error: any) {
logger.error(
`Error processing stats update job: ${error.message}`,

View File

@ -18,6 +18,9 @@ type Events = {
objectType: ObjectType;
visitType: VisitType;
};
updatePostState: {
id: string;
};
onMessageCreated: { data: Partial<MessageDto> };
dataChanged: { type: string; operation: CrudOperation; data: any };
};

View File

@ -3,7 +3,7 @@ import { Form, FormInstance } from "antd";
import { api, usePost } from "@nice/client";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { PostType } from "@nice/common";
import { PostState, PostType } from "@nice/common";
export interface LetterFormData {
title: string;
@ -52,6 +52,7 @@ export function LetterFormProvider({
id,
})),
},
state: PostState.PENDING,
isPublic: data?.isPublic,
...data,
resources: data.resources?.length

View File

@ -11,9 +11,11 @@ import { PostDetailContext } from "./context/PostDetailContext";
export default function PostCommentCard({
post,
index,
isReceiverComment
}: {
post: PostDto;
index: number;
isReceiverComment: boolean;
}) {
const { user } = useContext(PostDetailContext);
const { like } = useVisitor();
@ -50,6 +52,11 @@ export default function PostCommentCard({
<div
className="flex items-center space-x-2"
style={{ height: 40 }}>
{isReceiverComment && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
</span>
)}
<span className="font-medium text-slate-900">
{post.author?.showname || "匿名用户"}
</span>

View File

@ -13,15 +13,30 @@ import PostCommentCard from "./PostCommentCard";
import { useInView } from "react-intersection-observer";
import { LoadingCard } from "@web/src/components/models/post/detail/LoadingCard";
export default function PostCommentList() {
export default function PostCommentList({
official = true,
}: {
official?: boolean;
}) {
const { post } = useContext(PostDetailContext);
const { ref: loadMoreRef, inView } = useInView();
const { postParams } = useVisitor();
const receiverIds = useMemo(() => {
return (
post?.receivers?.map((receiver) => receiver.id).filter(Boolean) ||
[]
);
}, [post]);
const params: Prisma.PostFindManyArgs = useMemo(() => {
return {
where: {
parentId: post?.id,
type: PostType.POST_COMMENT,
authorId: official
? { in: receiverIds }
: {
notIn: receiverIds,
},
},
select: postDetailSelect,
orderBy: [
@ -91,7 +106,13 @@ export default function PostCommentList() {
duration: 0.2,
delay: index * 0.05,
}}>
<PostCommentCard post={comment} index={index} />
<PostCommentCard
post={comment}
index={index}
isReceiverComment={receiverIds.includes(
comment.authorId
)}
/>
</motion.div>
))}
</AnimatePresence>
@ -112,7 +133,7 @@ export default function PostCommentList() {
className="flex flex-col items-center py-4 space-y-2">
<div className="h-px w-16 bg-gradient-to-r from-transparent via-[#00308F]/30 to-transparent" />
<span className="text-sm text-gray-500 font-medium">
</span>
<motion.div
className="h-px w-16 bg-gradient-to-r from-transparent via-[#00308F]/30 to-transparent"

View File

@ -210,7 +210,7 @@ export default function PostHeader() {
className="flex items-center gap-2 px-4 py-2 bg-white text-[#2B4C7E] rounded-md shadow-md border border-[#97A9C4]/30">
<ChatBubbleLeftIcon className="h-5 w-5" />
<span className="font-medium">
{post?.commentsCount || 0}
{post?.commentsCount || 0}
</span>
</motion.div>
</motion.div>

View File

@ -1,17 +1,18 @@
import { api, usePost } from "@nice/client";
import { Post, postDetailSelect, PostDto, UserProfile } from "@nice/common";
import { useAuth } from "@web/src/providers/auth-provider";
import React, { createContext, ReactNode, useState } from "react";
import React, { createContext, ReactNode, useEffect, useState } from "react";
import { PostParams } from "@nice/client/src/singleton/DataHolder";
interface PostDetailContextType {
editId?: string; // 添加 editId
editId?: string;
post?: PostDto;
isLoading?: boolean;
user?: UserProfile;
}
interface PostFormProviderProps {
children: ReactNode;
editId?: string; // 添加 editId 参数
editId?: string;
}
export const PostDetailContext = createContext<PostDetailContextType | null>(
null
@ -21,15 +22,26 @@ export function PostDetailProvider({
editId,
}: PostFormProviderProps) {
const { user } = useAuth();
const postParams = PostParams.getInstance();
const queryParams = {
where: { id: editId },
select: postDetailSelect,
};
useEffect(() => {
if (editId) {
postParams.addDetailItem(queryParams);
}
return () => {
if (editId) {
postParams.removeDetailItem(queryParams);
}
};
}, [editId]);
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
api.post.findFirst as any
).useQuery(
{
where: { id: editId },
select: postDetailSelect,
},
{ enabled: Boolean(editId) }
);
).useQuery(queryParams, { enabled: Boolean(editId) });
return (
<PostDetailContext.Provider

View File

@ -22,21 +22,17 @@ export function useVisitor() {
//在请求发送前执行本地数据预更新
onMutate: async (variables: any) => {
const previousDataList: any[] = [];
// 动态生成参数列表,包括星标和其他参数
const previousDetailDataList: any[] = [];
// 处理列表数据
const paramsList = postParams.getItems();
console.log("paramsList.length", paramsList.length);
// 遍历所有参数列表,执行乐观更新
for (const params of paramsList) {
// 取消可能的并发请求
await utils.post.findManyWithCursor.cancel();
// 获取并保存当前数据
const previousData =
utils.post.findManyWithCursor.getInfiniteData({
...params,
});
previousDataList.push(previousData);
// 执行乐观更新
utils.post.findManyWithCursor.setInfiniteData(
{
...params,
@ -58,7 +54,21 @@ export function useVisitor() {
);
}
return { previousDataList };
// 处理详情数据
const detailParamsList = postParams.getDetailItems();
for (const params of detailParamsList) {
await utils.post.findFirst.cancel();
const previousDetailData = utils.post.findFirst.getData(params);
previousDetailDataList.push(previousDetailData);
utils.post.findFirst.setData(params, (oldData) => {
if (!oldData) return oldData;
return oldData.id === variables?.postId
? updateFn(oldData, variables)
: oldData;
});
}
return { previousDataList, previousDetailDataList };
},
// 错误处理:数据回滚
onError: (_err: any, _variables: any, context: any) => {

View File

@ -1,9 +1,11 @@
export class PostParams {
private static instance: PostParams; // 静态私有变量,用于存储单例实例
private postParams: Array<object>; // 私有数组属性,用于存储对象
private static instance: PostParams;
private postParams: Array<object>;
private postDetailParams: Array<object>;
private constructor() {
this.postParams = []; // 初始化空数组
this.postParams = [];
this.postDetailParams = [];
}
public static getInstance(): PostParams {
@ -14,7 +16,6 @@ export class PostParams {
}
public addItem(item: object): void {
// 使用更可靠的方式比较查询参数
const isDuplicate = this.postParams.some((existingItem: any) => {
if (item && existingItem) {
const itemWhere = (item as any).where;
@ -32,8 +33,22 @@ export class PostParams {
}
}
public addDetailItem(item: object): void {
const isDuplicate = this.postDetailParams.some((existingItem: any) => {
if (item && existingItem) {
const itemWhere = (item as any).where;
const existingWhere = existingItem.where;
return itemWhere?.id === existingWhere?.id;
}
return false;
});
if (!isDuplicate) {
this.postDetailParams.push(item);
}
}
public removeItem(item: object): void {
// 使用相同的比较逻辑移除项
this.postParams = this.postParams.filter((existingItem: any) => {
if (item && existingItem) {
const itemWhere = (item as any).where;
@ -47,7 +62,24 @@ export class PostParams {
});
}
public removeDetailItem(item: object): void {
this.postDetailParams = this.postDetailParams.filter(
(existingItem: any) => {
if (item && existingItem) {
const itemWhere = (item as any).where;
const existingWhere = existingItem.where;
return !(itemWhere?.id === existingWhere?.id);
}
return true;
}
);
}
public getItems(): Array<object> {
return [...this.postParams];
}
public getDetailItems(): Array<object> {
return [...this.postDetailParams];
}
}

View File

@ -190,6 +190,7 @@ model Post {
state String? // 状态 未读、处理中、已回答
title String? // 帖子标题,可为空
content String? // 帖子内容,可为空
domainId String? @map("domain_id")
term Term? @relation(fields: [termId], references: [id])
termId String? @map("term_id")

View File

@ -190,3 +190,9 @@ export const LessonTypeLabel = {
[LessonType.QUIZ]: "测验",
[LessonType.ASSIGNMENT]: "作业",
};
export enum PostState {
PENDING = "pending",
PROCESSING = "processing",
COMPLETED = "completed",
}