feat(api-security): encrypt sensitive data at rest
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
TaskStatus
|
||||
} from "../../generated/prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { DataEncryptionService } from "../security/data-encryption.service";
|
||||
import { AiProviderRegistryService } from "./ai-provider-registry.service";
|
||||
import { AiChatDto } from "./dto/ai-chat.dto";
|
||||
import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto";
|
||||
@@ -93,7 +94,8 @@ export class AiService {
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly aiProviderRegistryService: AiProviderRegistryService
|
||||
private readonly aiProviderRegistryService: AiProviderRegistryService,
|
||||
private readonly dataEncryptionService: DataEncryptionService
|
||||
) {}
|
||||
|
||||
async listBindings(userId: string): Promise<ListAiBindingsResponse> {
|
||||
@@ -119,8 +121,8 @@ export class AiService {
|
||||
publicPool: publicPool
|
||||
? {
|
||||
enabled: publicPool.enabled,
|
||||
providerName: publicPool.providerName,
|
||||
model: publicPool.model,
|
||||
providerName: this.readDecryptedString(publicPool.providerName),
|
||||
model: this.readDecryptedString(publicPool.model),
|
||||
hasApiKey: Boolean(publicPool.encryptedApiKey)
|
||||
}
|
||||
: null
|
||||
@@ -191,12 +193,12 @@ export class AiService {
|
||||
data: {
|
||||
userId,
|
||||
channel: dto.channel,
|
||||
providerName: this.normalizeProviderName(dto.providerName),
|
||||
model: this.normalizeOptionalString(dto.model),
|
||||
configId: this.normalizeOptionalString(dto.configId),
|
||||
configName: this.normalizeOptionalString(dto.configName),
|
||||
endpoint: this.normalizeOptionalString(dto.endpoint),
|
||||
encryptedApiKey: this.normalizeOptionalString(dto.apiKey),
|
||||
providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)),
|
||||
model: this.encryptOptionalString(dto.model),
|
||||
configId: this.encryptOptionalString(dto.configId),
|
||||
configName: this.encryptOptionalString(dto.configName),
|
||||
endpoint: this.encryptOptionalString(dto.endpoint),
|
||||
encryptedApiKey: this.encryptOptionalString(dto.apiKey),
|
||||
isEnabled: dto.isEnabled ?? true
|
||||
}
|
||||
});
|
||||
@@ -204,19 +206,19 @@ export class AiService {
|
||||
|
||||
const updateData: Prisma.AiProviderBindingUpdateInput = {
|
||||
channel: dto.channel,
|
||||
providerName: this.normalizeProviderName(dto.providerName),
|
||||
model: this.normalizeOptionalString(dto.model),
|
||||
configId: this.normalizeOptionalString(dto.configId),
|
||||
configName: this.normalizeOptionalString(dto.configName),
|
||||
providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)),
|
||||
model: this.encryptOptionalString(dto.model),
|
||||
configId: this.encryptOptionalString(dto.configId),
|
||||
configName: this.encryptOptionalString(dto.configName),
|
||||
isEnabled: dto.isEnabled ?? existingBinding.isEnabled
|
||||
};
|
||||
|
||||
if (dto.endpoint !== undefined) {
|
||||
updateData.endpoint = this.normalizeOptionalString(dto.endpoint);
|
||||
updateData.endpoint = this.encryptOptionalString(dto.endpoint);
|
||||
}
|
||||
|
||||
if (dto.apiKey !== undefined) {
|
||||
updateData.encryptedApiKey = this.normalizeOptionalString(dto.apiKey);
|
||||
updateData.encryptedApiKey = this.encryptOptionalString(dto.apiKey);
|
||||
}
|
||||
|
||||
return tx.aiProviderBinding.update({
|
||||
@@ -398,12 +400,12 @@ export class AiService {
|
||||
channel: binding.channel,
|
||||
source: "binding",
|
||||
sourceId: binding.id,
|
||||
providerName: binding.providerName,
|
||||
model: binding.model,
|
||||
configId: binding.configId,
|
||||
configName: binding.configName,
|
||||
endpoint: binding.endpoint,
|
||||
apiKey: binding.encryptedApiKey
|
||||
providerName: this.readDecryptedString(binding.providerName) ?? "",
|
||||
model: this.readDecryptedString(binding.model),
|
||||
configId: this.readDecryptedString(binding.configId),
|
||||
configName: this.readDecryptedString(binding.configName),
|
||||
endpoint: this.readDecryptedString(binding.endpoint),
|
||||
apiKey: this.readDecryptedString(binding.encryptedApiKey)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -412,27 +414,34 @@ export class AiService {
|
||||
channel: AiChannel.PUBLIC_POOL,
|
||||
source: "public_pool",
|
||||
sourceId: publicPool.id,
|
||||
providerName: publicPool.providerName ?? "public-pool",
|
||||
model: publicPool.model,
|
||||
providerName: this.readDecryptedString(publicPool.providerName) ?? "public-pool",
|
||||
model: this.readDecryptedString(publicPool.model),
|
||||
configId: null,
|
||||
configName: null,
|
||||
endpoint: publicPool.endpoint,
|
||||
apiKey: publicPool.encryptedApiKey
|
||||
endpoint: this.readDecryptedString(publicPool.endpoint),
|
||||
apiKey: this.readDecryptedString(publicPool.encryptedApiKey)
|
||||
};
|
||||
}
|
||||
|
||||
private serializeBinding(binding: AiProviderBinding): AiBindingSummary {
|
||||
const decryptedProviderName = this.readDecryptedString(binding.providerName) ?? "";
|
||||
const decryptedModel = this.readDecryptedString(binding.model);
|
||||
const decryptedConfigId = this.readDecryptedString(binding.configId);
|
||||
const decryptedConfigName = this.readDecryptedString(binding.configName);
|
||||
const decryptedEndpoint = this.readDecryptedString(binding.endpoint);
|
||||
const decryptedApiKey = this.readDecryptedString(binding.encryptedApiKey);
|
||||
|
||||
return {
|
||||
id: binding.id,
|
||||
channel: binding.channel,
|
||||
providerName: binding.providerName,
|
||||
model: binding.model,
|
||||
configId: binding.configId,
|
||||
configName: binding.configName,
|
||||
endpoint: binding.endpoint,
|
||||
providerName: decryptedProviderName,
|
||||
model: decryptedModel,
|
||||
configId: decryptedConfigId,
|
||||
configName: decryptedConfigName,
|
||||
endpoint: decryptedEndpoint,
|
||||
isEnabled: binding.isEnabled,
|
||||
hasApiKey: Boolean(binding.encryptedApiKey),
|
||||
maskedApiKey: this.maskSecret(binding.encryptedApiKey),
|
||||
maskedApiKey: this.maskSecret(decryptedApiKey),
|
||||
updatedAt: binding.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
@@ -523,14 +532,16 @@ export class AiService {
|
||||
|
||||
const visibleTasks = sortedTasks.slice(0, this.maxContextTasks);
|
||||
const lines = visibleTasks.map((task, index) => {
|
||||
const taskTitle = this.readDecryptedString(task.title) ?? "未命名任务";
|
||||
const contentText = this.readDecryptedString(task.contentText);
|
||||
const parts = [
|
||||
`${index + 1}. ${task.title}`,
|
||||
`${index + 1}. ${taskTitle}`,
|
||||
`优先级:${this.getPriorityLabel(task.priority)}`,
|
||||
`状态:${this.getStatusLabel(task.status)}`,
|
||||
`DDL:${task.ddl ? task.ddl.toISOString() : "未设置"}`
|
||||
];
|
||||
|
||||
const contentSnippet = this.getContentSnippet(task.contentText);
|
||||
const contentSnippet = this.getContentSnippet(contentText);
|
||||
if (contentSnippet) {
|
||||
parts.push(`内容摘要:${contentSnippet}`);
|
||||
}
|
||||
@@ -592,6 +603,25 @@ export class AiService {
|
||||
return this.normalizeOptionalString(value) ?? "";
|
||||
}
|
||||
|
||||
private encryptOptionalString(value: string | undefined): string | null | undefined {
|
||||
const normalizedValue = this.normalizeOptionalString(value);
|
||||
return this.dataEncryptionService.encryptString(normalizedValue);
|
||||
}
|
||||
|
||||
private encryptRequiredString(value: string): string {
|
||||
const encryptedValue = this.dataEncryptionService.encryptString(value);
|
||||
if (!encryptedValue) {
|
||||
throw new BadRequestException("敏感配置加密失败");
|
||||
}
|
||||
|
||||
return encryptedValue;
|
||||
}
|
||||
|
||||
private readDecryptedString(value: string | null): string | null {
|
||||
const decryptedValue = this.dataEncryptionService.decryptString(value);
|
||||
return typeof decryptedValue === "string" ? decryptedValue : null;
|
||||
}
|
||||
|
||||
private validateBindingInput(dto: UpsertAiProviderBindingDto): void {
|
||||
const providerName = this.normalizeOptionalString(dto.providerName);
|
||||
const configId = this.normalizeOptionalString(dto.configId);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AiModule } from "./ai/ai.module";
|
||||
import { AttachmentModule } from "./attachment/attachment.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { SecurityModule } from "./security/security.module";
|
||||
import { SyncModule } from "./sync/sync.module";
|
||||
import { TaskModule } from "./task/task.module";
|
||||
|
||||
@@ -14,6 +15,7 @@ import { TaskModule } from "./task/task.module";
|
||||
envFilePath: ".env"
|
||||
}),
|
||||
PrismaModule,
|
||||
SecurityModule,
|
||||
AuthModule,
|
||||
TaskModule,
|
||||
AttachmentModule,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Injectable, NotFoundException, PayloadTooLargeException } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { AttachmentType } from "../../generated/prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { DataEncryptionService } from "../security/data-encryption.service";
|
||||
import { CompleteAttachmentDto } from "./dto/complete-attachment.dto";
|
||||
import { PresignAttachmentDto } from "./dto/presign-attachment.dto";
|
||||
|
||||
@@ -25,9 +31,7 @@ export type PresignAttachmentResponse = {
|
||||
usedBytes: string;
|
||||
remainingBytes: string;
|
||||
};
|
||||
headers: {
|
||||
"Content-Type": string;
|
||||
};
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AttachmentResponse = {
|
||||
@@ -52,7 +56,8 @@ export class AttachmentService {
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly dataEncryptionService: DataEncryptionService
|
||||
) {}
|
||||
|
||||
async presignAttachment(
|
||||
@@ -67,15 +72,17 @@ export class AttachmentService {
|
||||
}
|
||||
|
||||
const bucket = this.getDefaultBucket();
|
||||
const objectKey = this.generateObjectKey(userId, body.fileName);
|
||||
const objectKey = this.generateObjectKey(body.fileName);
|
||||
const objectUrl = this.resolveObjectUrl(bucket, objectKey);
|
||||
const expiresInSeconds = this.getPresignExpiresInSeconds();
|
||||
const serverSideEncryption = this.getServerSideEncryptionMode();
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey,
|
||||
ContentType: body.mimeType,
|
||||
ContentLength: body.fileSize
|
||||
ContentLength: body.fileSize,
|
||||
ServerSideEncryption: serverSideEncryption
|
||||
});
|
||||
|
||||
const uploadUrl = await getSignedUrl(this.getS3Client(), command, {
|
||||
@@ -94,9 +101,7 @@ export class AttachmentService {
|
||||
usedBytes: quotaInfo.usedBytes.toString(),
|
||||
remainingBytes: (quotaInfo.totalBytes - quotaInfo.usedBytes).toString()
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": body.mimeType
|
||||
}
|
||||
headers: this.buildUploadHeaders(body.mimeType, serverSideEncryption)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,14 +144,14 @@ export class AttachmentService {
|
||||
userId,
|
||||
taskId: body.taskId ?? null,
|
||||
type: body.type ?? this.resolveAttachmentType(body.mimeType),
|
||||
url: objectUrl,
|
||||
url: this.encryptRequiredString(objectUrl),
|
||||
mimeType: body.mimeType,
|
||||
fileName: body.fileName,
|
||||
fileName: this.encryptNullableString(body.fileName),
|
||||
fileSize: body.fileSize,
|
||||
width: body.width ?? null,
|
||||
height: body.height ?? null,
|
||||
durationMs: body.durationMs ?? null,
|
||||
checksum: body.checksum ?? null
|
||||
checksum: this.encryptNullableString(body.checksum)
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -155,14 +160,14 @@ export class AttachmentService {
|
||||
id: attachment.id,
|
||||
taskId: attachment.taskId,
|
||||
type: attachment.type,
|
||||
url: attachment.url,
|
||||
url: this.readDecryptedString(attachment.url) ?? objectUrl,
|
||||
mimeType: attachment.mimeType,
|
||||
fileName: attachment.fileName,
|
||||
fileName: this.readDecryptedString(attachment.fileName),
|
||||
fileSize: attachment.fileSize,
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
durationMs: attachment.durationMs,
|
||||
checksum: attachment.checksum,
|
||||
checksum: this.readDecryptedString(attachment.checksum),
|
||||
createdAt: attachment.createdAt.toISOString(),
|
||||
updatedAt: attachment.updatedAt.toISOString()
|
||||
};
|
||||
@@ -204,10 +209,9 @@ export class AttachmentService {
|
||||
return Math.min(configValue, 604800);
|
||||
}
|
||||
|
||||
private generateObjectKey(userId: string, fileName: string): string {
|
||||
const safeFileName = fileName.replace(/[^\w.-]+/g, "_");
|
||||
private generateObjectKey(fileName: string): string {
|
||||
const datePrefix = new Date().toISOString().slice(0, 10);
|
||||
return `${userId}/${datePrefix}/${randomUUID()}-${safeFileName}`;
|
||||
return `attachments/${datePrefix}/${randomUUID()}${this.extractFileExtension(fileName)}`;
|
||||
}
|
||||
|
||||
private resolveObjectUrl(bucket: string, objectKey: string): string {
|
||||
@@ -232,6 +236,37 @@ export class AttachmentService {
|
||||
return AttachmentType.FILE;
|
||||
}
|
||||
|
||||
private buildUploadHeaders(
|
||||
mimeType: string,
|
||||
serverSideEncryption: "AES256" | undefined
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": mimeType
|
||||
};
|
||||
|
||||
if (serverSideEncryption) {
|
||||
headers["x-amz-server-side-encryption"] = serverSideEncryption;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private getServerSideEncryptionMode(): "AES256" | undefined {
|
||||
const configValue =
|
||||
this.configService.get<string>("S3_SERVER_SIDE_ENCRYPTION")?.trim().toUpperCase() ?? "AES256";
|
||||
|
||||
if (configValue === "NONE" || configValue === "DISABLED") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return "AES256";
|
||||
}
|
||||
|
||||
private extractFileExtension(fileName: string): string {
|
||||
const match = /\.[a-zA-Z0-9]{1,16}$/.exec(fileName);
|
||||
return match?.[0]?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
private async ensureTaskOwnership(userId: string, taskId: string): Promise<void> {
|
||||
const task = await this.prismaService.task.findFirst({
|
||||
where: {
|
||||
@@ -279,4 +314,22 @@ export class AttachmentService {
|
||||
throw new PayloadTooLargeException("存储配额不足");
|
||||
}
|
||||
}
|
||||
|
||||
private encryptRequiredString(value: string): string {
|
||||
const encryptedValue = this.dataEncryptionService.encryptString(value);
|
||||
if (!encryptedValue) {
|
||||
throw new InternalServerErrorException("附件元数据加密失败");
|
||||
}
|
||||
|
||||
return encryptedValue;
|
||||
}
|
||||
|
||||
private encryptNullableString(value: string | null | undefined): string | null | undefined {
|
||||
return this.dataEncryptionService.encryptString(value);
|
||||
}
|
||||
|
||||
private readDecryptedString(value: string | null): string | null {
|
||||
const decryptedValue = this.dataEncryptionService.decryptString(value);
|
||||
return typeof decryptedValue === "string" ? decryptedValue : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Prisma } from "../../generated/prisma/client";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const ENCRYPTION_PREFIX = "encv1";
|
||||
const ENCRYPTION_ALGORITHM = "aes-256-gcm";
|
||||
const ENCRYPTION_IV_LENGTH = 12;
|
||||
|
||||
@Injectable()
|
||||
export class DataEncryptionService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(this.configService.get<string>("DATA_ENCRYPTION_SECRET"));
|
||||
}
|
||||
|
||||
isEncryptedString(value: string): boolean {
|
||||
return value.startsWith(`${ENCRYPTION_PREFIX}:`);
|
||||
}
|
||||
|
||||
encryptString(value: string | null | undefined): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = this.resolveKey();
|
||||
const iv = randomBytes(ENCRYPTION_IV_LENGTH);
|
||||
const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
ENCRYPTION_PREFIX,
|
||||
iv.toString("base64url"),
|
||||
authTag.toString("base64url"),
|
||||
encrypted.toString("base64url")
|
||||
].join(":");
|
||||
}
|
||||
|
||||
decryptString(value: string | null | undefined): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value === null || !this.isEncryptedPayload(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const [prefix, ivText, authTagText, encryptedText] = value.split(":");
|
||||
if (prefix !== ENCRYPTION_PREFIX || !ivText || !authTagText || encryptedText === undefined) {
|
||||
throw new InternalServerErrorException("加密数据格式无效");
|
||||
}
|
||||
|
||||
try {
|
||||
const key = this.resolveKey();
|
||||
const decipher = createDecipheriv(
|
||||
ENCRYPTION_ALGORITHM,
|
||||
key,
|
||||
Buffer.from(ivText, "base64url")
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(authTagText, "base64url"));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedText, "base64url")),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
return decrypted.toString("utf8");
|
||||
} catch {
|
||||
throw new InternalServerErrorException("加密数据解密失败");
|
||||
}
|
||||
}
|
||||
|
||||
encryptJson(
|
||||
value: Prisma.InputJsonValue | null | undefined
|
||||
): Prisma.InputJsonValue | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.encryptString(JSON.stringify(value));
|
||||
}
|
||||
|
||||
decryptJson(value: Prisma.JsonValue | null): Prisma.JsonValue | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string" || !this.isEncryptedPayload(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const decrypted = this.decryptString(value);
|
||||
if (typeof decrypted !== "string") {
|
||||
throw new InternalServerErrorException("加密数据解密失败");
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decrypted) as Prisma.JsonValue;
|
||||
} catch {
|
||||
throw new InternalServerErrorException("加密 JSON 数据损坏");
|
||||
}
|
||||
}
|
||||
|
||||
decryptPayload(value: Prisma.JsonValue | null): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return this.decryptString(value) ?? null;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
private isEncryptedPayload(value: string): boolean {
|
||||
return this.isEncryptedString(value);
|
||||
}
|
||||
|
||||
private resolveKey(): Buffer {
|
||||
const secret = this.configService.get<string>("DATA_ENCRYPTION_SECRET");
|
||||
if (!secret) {
|
||||
throw new InternalServerErrorException(
|
||||
"服务端未配置 DATA_ENCRYPTION_SECRET,无法写入加密数据"
|
||||
);
|
||||
}
|
||||
|
||||
return createHash("sha256").update(secret, "utf8").digest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { DataEncryptionService } from "./data-encryption.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DataEncryptionService],
|
||||
exports: [DataEncryptionService]
|
||||
})
|
||||
export class SecurityModule {}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "../../generated/prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { DataEncryptionService } from "../security/data-encryption.service";
|
||||
import { SyncPullQueryDto } from "./dto/sync-pull.dto";
|
||||
import { SyncPushDto, SyncPushOperationDto } from "./dto/sync-push.dto";
|
||||
|
||||
@@ -60,7 +61,10 @@ export type SyncPullResponse = {
|
||||
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly dataEncryptionService: DataEncryptionService
|
||||
) {}
|
||||
|
||||
async pullOperations(userId: string, query: SyncPullQueryDto): Promise<SyncPullResponse> {
|
||||
const limit = query.limit ?? 100;
|
||||
@@ -137,7 +141,7 @@ export class SyncService {
|
||||
entityType: operation.entityType,
|
||||
entityId: operation.entityId,
|
||||
action: operation.action,
|
||||
payload: operation.payload,
|
||||
payload: this.dataEncryptionService.encryptString(operation.payload) ?? undefined,
|
||||
clientTs: new Date(operation.clientTs)
|
||||
},
|
||||
select: {
|
||||
@@ -252,15 +256,7 @@ export class SyncService {
|
||||
}
|
||||
|
||||
private serializePayload(payload: Prisma.JsonValue | null): string | null {
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload);
|
||||
return this.dataEncryptionService.decryptPayload(payload);
|
||||
}
|
||||
|
||||
private parseCursor(cursor: string | undefined): SyncPullCursorState | null {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma, TaskPriority, TaskStatus } from "../../generated/prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { DataEncryptionService } from "../security/data-encryption.service";
|
||||
import { CreateTaskDto } from "./dto/create-task.dto";
|
||||
import { ListTasksQueryDto, TaskSortBy, TaskSortOrder } from "./dto/list-tasks-query.dto";
|
||||
import { UpdateTaskDto } from "./dto/update-task.dto";
|
||||
@@ -43,16 +44,48 @@ export type ListTasksResponse = {
|
||||
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly dataEncryptionService: DataEncryptionService
|
||||
) {}
|
||||
|
||||
async listTasks(userId: string, query: ListTasksQueryDto): Promise<ListTasksResponse> {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const keyword = query.keyword?.trim() ?? "";
|
||||
|
||||
const where = this.buildWhereInput(userId, query);
|
||||
const where = this.buildWhereInput(userId, query, keyword.length === 0);
|
||||
const orderBy = this.buildOrderByInput(query);
|
||||
|
||||
if (keyword.length > 0) {
|
||||
const items = await this.prismaService.task.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
include: {
|
||||
taskTags: {
|
||||
include: {
|
||||
tag: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const serializedItems = items.map((item: TaskEntity) => this.serializeTask(item));
|
||||
const filteredItems = serializedItems.filter((item) => this.matchesKeyword(item, keyword));
|
||||
|
||||
return {
|
||||
items: filteredItems.slice(skip, skip + pageSize),
|
||||
page,
|
||||
pageSize,
|
||||
total: filteredItems.length
|
||||
};
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prismaService.task.findMany({
|
||||
where,
|
||||
@@ -112,15 +145,18 @@ export class TaskService {
|
||||
const tagNames = this.normalizeTagNames(body.tagNames);
|
||||
const nextStatus = body.status ?? TaskStatus.TODO;
|
||||
const contentJson =
|
||||
body.contentJson !== undefined ? (body.contentJson as Prisma.InputJsonValue) : undefined;
|
||||
body.contentJson !== undefined
|
||||
? ((this.dataEncryptionService.encryptJson(body.contentJson as Prisma.InputJsonValue) ??
|
||||
Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput)
|
||||
: undefined;
|
||||
|
||||
const task = await this.prismaService.$transaction(async (tx) => {
|
||||
const createdTask = await tx.task.create({
|
||||
data: {
|
||||
userId,
|
||||
title: body.title,
|
||||
title: this.encryptRequiredString(body.title),
|
||||
contentJson,
|
||||
contentText: body.contentText ?? null,
|
||||
contentText: this.encryptNullableString(body.contentText),
|
||||
priority: body.priority ?? TaskPriority.MEDIUM,
|
||||
status: nextStatus,
|
||||
ddl: body.ddl ? new Date(body.ddl) : null,
|
||||
@@ -172,13 +208,15 @@ export class TaskService {
|
||||
};
|
||||
|
||||
if (body.title !== undefined) {
|
||||
data.title = body.title;
|
||||
data.title = this.encryptRequiredString(body.title);
|
||||
}
|
||||
if (body.contentJson !== undefined) {
|
||||
data.contentJson = body.contentJson as Prisma.InputJsonValue;
|
||||
data.contentJson = (this.dataEncryptionService.encryptJson(
|
||||
body.contentJson as Prisma.InputJsonValue
|
||||
) ?? Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput;
|
||||
}
|
||||
if (body.contentText !== undefined) {
|
||||
data.contentText = body.contentText;
|
||||
data.contentText = this.encryptNullableString(body.contentText);
|
||||
}
|
||||
if (body.priority !== undefined) {
|
||||
data.priority = body.priority;
|
||||
@@ -242,7 +280,11 @@ export class TaskService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private buildWhereInput(userId: string, query: ListTasksQueryDto): Prisma.TaskWhereInput {
|
||||
private buildWhereInput(
|
||||
userId: string,
|
||||
query: ListTasksQueryDto,
|
||||
includeKeyword: boolean
|
||||
): Prisma.TaskWhereInput {
|
||||
const where: Prisma.TaskWhereInput = {
|
||||
userId
|
||||
};
|
||||
@@ -267,7 +309,7 @@ export class TaskService {
|
||||
};
|
||||
}
|
||||
|
||||
if (query.keyword !== undefined && query.keyword.length > 0) {
|
||||
if (includeKeyword && query.keyword !== undefined && query.keyword.length > 0) {
|
||||
where.OR = [
|
||||
{
|
||||
title: {
|
||||
@@ -374,9 +416,9 @@ export class TaskService {
|
||||
private serializeTask(task: TaskEntity): TaskResponse {
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
contentJson: task.contentJson,
|
||||
contentText: task.contentText,
|
||||
title: this.readDecryptedString(task.title) ?? "未命名任务",
|
||||
contentJson: this.dataEncryptionService.decryptJson(task.contentJson),
|
||||
contentText: this.readDecryptedString(task.contentText),
|
||||
priority: task.priority,
|
||||
status: task.status,
|
||||
ddl: task.ddl?.toISOString() ?? null,
|
||||
@@ -387,4 +429,30 @@ export class TaskService {
|
||||
updatedAt: task.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private encryptRequiredString(value: string): string {
|
||||
const encryptedValue = this.dataEncryptionService.encryptString(value);
|
||||
if (!encryptedValue) {
|
||||
throw new InternalServerErrorException("任务字段加密失败");
|
||||
}
|
||||
|
||||
return encryptedValue;
|
||||
}
|
||||
|
||||
private encryptNullableString(value: string | null | undefined): string | null | undefined {
|
||||
return this.dataEncryptionService.encryptString(value);
|
||||
}
|
||||
|
||||
private readDecryptedString(value: string | null): string | null {
|
||||
const decryptedValue = this.dataEncryptionService.decryptString(value);
|
||||
return typeof decryptedValue === "string" ? decryptedValue : null;
|
||||
}
|
||||
|
||||
private matchesKeyword(task: TaskResponse, keyword: string): boolean {
|
||||
const lowerKeyword = keyword.toLocaleLowerCase();
|
||||
return (
|
||||
task.title.toLocaleLowerCase().includes(lowerKeyword) ||
|
||||
task.contentText?.toLocaleLowerCase().includes(lowerKeyword) === true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user