Skip to content
Open
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
11 changes: 11 additions & 0 deletions server/src/models/surveyMeta.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export class SurveyMeta extends BaseEntity {
@Column()
deletedAt: Date;

@Column({ default: false })
isPermanentDeleted: boolean; // 是否彻底删除

@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
Expand All @@ -82,5 +85,13 @@ export class SurveyMeta extends BaseEntity {
const subStatus = { status: RECORD_SUB_STATUS.DEFAULT, date: now };
this.subStatus = subStatus;
}

if (this.isDeleted === undefined || this.isDeleted === null) {
this.isDeleted = false;
}

if (this.isPermanentDeleted === undefined || this.isPermanentDeleted === null) {
this.isPermanentDeleted = false;
}
}
}
142 changes: 142 additions & 0 deletions server/src/modules/survey/controllers/recycleBin.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
Controller,
Post,
Body,
HttpCode,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Authentication } from 'src/guards/authentication.guard';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { Logger } from 'src/logger';

import { RecycleBinService } from '../services/recycleBin.service';
import { RecycleBinListDto, RecycleBinItemDto } from '../dto/recycleBin.dto';

@ApiTags('回收站')
@Controller('/api/survey/recyclebin')
@UseGuards(Authentication)
export class RecycleBinController {
constructor(
private readonly recycleBinService: RecycleBinService,
private readonly logger: Logger,
) {}

@Post('/list')
@HttpCode(200)
@ApiOperation({ summary: '获取回收站问卷列表' })
@ApiResponse({ status: 200, description: '操作成功' })
async getRecycleBinList(@Request() req, @Body() dto: RecycleBinListDto) {
const { value, error } = RecycleBinListDto.validate(dto);

if (error) {
this.logger.error(`getRecycleBinList_parameter error: ${error.message}`);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}

const userId = req.user._id.toString();
const { list, count } = await this.recycleBinService.getRecycleBinList(userId, value);

return {
code: 200,
data: {
list,
count,
},
};
}

@Post('/restore')
@HttpCode(200)
@ApiOperation({ summary: '恢复问卷' })
@ApiResponse({ status: 200, description: '操作成功' })
async restoreSurvey(@Request() req, @Body() dto: RecycleBinItemDto) {
const { value, error } = RecycleBinItemDto.validate(dto);

if (error) {
this.logger.error(`restoreSurvey_parameter error: ${error.message}`);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}

const userId = req.user._id.toString();
await this.recycleBinService.restoreSurvey(userId, value.id, value.surveyPath);

return {
code: 200,
data: {
success: true,
},
};
}

@Post('/delete')
@HttpCode(200)
@ApiOperation({ summary: '永久删除问卷' })
@ApiResponse({ status: 200, description: '操作成功' })
async permanentDeleteSurvey(@Request() req, @Body() dto: RecycleBinItemDto) {
const { value, error } = RecycleBinItemDto.validate(dto);

if (error) {
this.logger.error(`permanentDeleteSurvey_parameter error: ${error.message}`);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}

const userId = req.user._id.toString();
await this.recycleBinService.permanentDeleteSurvey(userId, value.id);

return {
code: 200,
data: {
success: true,
},
};
}

@Post('/move')
@HttpCode(200)
@ApiOperation({ summary: '将问卷移至回收站' })
@ApiResponse({ status: 200, description: '操作成功' })
async moveToRecycleBin(@Request() req, @Body() dto: RecycleBinItemDto) {
console.log(`[RecycleBin] 收到移动问卷请求:`, JSON.stringify(dto));
this.logger.info(`moveToRecycleBin_start: ${JSON.stringify(dto)}`);
this.logger.info(`req.user: ${JSON.stringify(req.user)}`);
const { value, error } = RecycleBinItemDto.validate(dto);

if (error) {
const errorMsg = `moveToRecycleBin_parameter error: ${error.message}`;
console.error(errorMsg);
this.logger.error(errorMsg);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}

try {
const userId = req.user._id.toString();
console.log(`[RecycleBin] 处理移动问卷: userId=${userId}, surveyId=${value.id}`);
this.logger.info(`moveToRecycleBin_processing: userId=${userId}, surveyId=${value.id}`);

await this.recycleBinService.moveToRecycleBin(userId, value.id, value.surveyPath);

console.log(`[RecycleBin] 成功移动问卷: surveyId=${value.id}`);
this.logger.info(`moveToRecycleBin_success: userId=${userId}, surveyId=${value.id}`);
return {
code: 200,
data: {
success: true,
},
};
} catch (error) {
const errorMsg = `moveToRecycleBin_error: ${error.message || '未知错误'}`;
console.error(`[RecycleBin] 错误:`, errorMsg);
console.error(`[RecycleBin] 堆栈:`, error.stack);
this.logger.error(errorMsg);
this.logger.error(`moveToRecycleBin_stack: ${error.stack || 'No stack trace'}`);

return {
code: 500,
errmsg: error.message || '移动到回收站失败',
};
}
}
}
32 changes: 32 additions & 0 deletions server/src/modules/survey/dto/recycleBin.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import Joi from 'joi';

export class RecycleBinListDto {
@ApiProperty({ description: '当前页码', required: true })
curPage: number;

@ApiProperty({ description: '分页大小', required: false })
pageSize: number;

static validate(data) {
return Joi.object({
curPage: Joi.number().required(),
pageSize: Joi.number().allow(null).default(10),
}).validate(data);
}
}

export class RecycleBinItemDto {
@ApiProperty({ description: '问卷ID', required: true })
id: string;

@ApiProperty({ description: '问卷路径', required: true })
surveyPath: string;

static validate(data) {
return Joi.object({
id: Joi.string().required(),
surveyPath: Joi.string().required(),
}).validate(data);
}
}
189 changes: 189 additions & 0 deletions server/src/modules/survey/services/recycleBin.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ObjectId } from 'mongodb';
import { SurveyMeta } from '../../../models/surveyMeta.entity';
import { RecycleBinListDto } from '../dto/recycleBin.dto';
import { CollaboratorService } from './collaborator.service';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { RECORD_STATUS } from '../../../enums';
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { MongoRepository } from 'typeorm';

@Injectable()
export class RecycleBinService {
private readonly logger = new Logger(RecycleBinService.name);

constructor(
@InjectRepository(SurveyMeta)
private readonly surveyMetaRepository: Repository<SurveyMeta>,
private readonly collaboratorService: CollaboratorService,
@InjectRepository(ResponseSchema)
private readonly responseSchemaRepository: MongoRepository<ResponseSchema>,
) {}

// 检查用户是否有权限操作该问卷
private async checkPermission(userId: string, surveyId: string) {
const survey = await this.surveyMetaRepository.findOne({
where: { _id: new ObjectId(surveyId) }
});

if (!survey) {
throw new NotFoundException('问卷不存在');
}

if (survey.ownerId === userId) {
return survey;
}

const collaborator = await this.collaboratorService.getCollaborator({ userId, surveyId });
this.logger.debug('协作者权限检查:', collaborator);

if (
collaborator &&
Array.isArray(collaborator.permissions) &&
collaborator.permissions.includes(SURVEY_PERMISSION.SURVEY_CONF_MANAGE)
) {
return survey;
}

throw new NotFoundException('没有操作权限');
}

async getRecycleBinList(userId: string, dto: RecycleBinListDto) {
const { value: params } = RecycleBinListDto.validate(dto);
const { curPage, pageSize } = params;
const skip = (curPage - 1) * pageSize;

const query = {
$or: [{ ownerId: userId }],
isDeleted: true,
isPermanentDeleted: false,
};

const [list, count] = await this.surveyMetaRepository.findAndCount({
where: query,
select: ['_id', 'title', 'createdAt', 'deletedAt', 'ownerId', 'surveyPath'],
skip,
take: pageSize,
order: {
deletedAt: 'DESC',
},
});

return { list, count };
}

async restoreSurvey(userId: string, id: string, surveyPath: string) {
const survey = await this.checkPermission(userId, id);

const dbSurvey = await this.surveyMetaRepository.findOne({
where: {
_id: new ObjectId(id),
surveyPath: surveyPath,
isDeleted: true,
ownerId: survey.ownerId,
},
});

if (!dbSurvey) {
throw new NotFoundException('问卷不存在或已被恢复');
}

const now = Date.now();

// 如果当前状态为 PUBLISHED,则恢复为 NEW 状态
if (dbSurvey.curStatus?.status === RECORD_STATUS.PUBLISHED) {
dbSurvey.curStatus = { status: RECORD_STATUS.NEW, date: now };
dbSurvey.statusList = Array.isArray(dbSurvey.statusList)
? [...dbSurvey.statusList, { status: RECORD_STATUS.NEW, date: now }]
: [{ status: RECORD_STATUS.NEW, date: now }];
}

dbSurvey.isDeleted = false;
dbSurvey.deletedAt = null;

await this.surveyMetaRepository.save(dbSurvey);

return { success: true };
}

async permanentDeleteSurvey(userId: string, id: string) {
await this.checkPermission(userId, id);

const survey = await this.surveyMetaRepository.findOne({
where: {
_id: new ObjectId(id),
isDeleted: true,
ownerId: userId,
},
});

if (!survey) {
throw new NotFoundException('问卷不存在');
}

await this.surveyMetaRepository.update(
{ _id: new ObjectId(id) },
{
isPermanentDeleted: true,
updatedAt: new Date(),
}
);

return { success: true };
}

async moveToRecycleBin(userId: string, id: string, surveyPath: string){

await this.checkPermission(userId, id);

let objectId: ObjectId;
try {
objectId = new ObjectId(id);
} catch (err) {
throw new NotFoundException(`无效的问卷ID: ${id}`);
}

const survey = await this.surveyMetaRepository.findOne({
where: {
_id: objectId,
isDeleted: false,
ownerId: userId,
},
});

if (!survey) {
throw new NotFoundException('问卷不存在或已在回收站');
}

const updateResult = await this.surveyMetaRepository.update(
{ _id: objectId },
{
isDeleted: true,
deletedAt: new Date(),
},
);

// 同步 C 端状态
const surveyC = await this.responseSchemaRepository.findOne({
where: { surveyPath: surveyPath}
});

if (surveyC && !surveyC.isDeleted) {
await this.responseSchemaRepository.update(
{ surveyPath: surveyPath },
{
isDeleted: true, // C 端的删除状态字段为 isDeleted
updatedAt: new Date()
}
);
}

if (updateResult.affected === 0) {
throw new Error('更新问卷状态失败');
}

return { success: true };
}
}
Loading
Loading