feat(api-security): encrypt sensitive data at rest
This commit is contained in:
@@ -63,3 +63,11 @@ MAIL_SMTP_PASS="replace-with-smtp-password"
|
||||
# 发件人显示名称与地址
|
||||
MAIL_FROM_NAME="TodoList"
|
||||
MAIL_FROM_ADDRESS="no-reply@example.com"
|
||||
|
||||
# [数据加密] 服务端敏感数据加密主密钥
|
||||
# 用于加密 AI 配置、任务内容、同步 payload、附件元数据等数据库字段
|
||||
# 请使用高强度随机字符串,生产环境务必单独保管
|
||||
DATA_ENCRYPTION_SECRET="replace-with-a-long-random-secret"
|
||||
|
||||
# [对象存储加密] 服务端对象加密策略,默认使用 AES256;如需关闭可填写 NONE
|
||||
S3_SERVER_SIDE_ENCRYPTION="AES256"
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
"version": "0.1.0",
|
||||
"description": "TodoList API service",
|
||||
"scripts": {
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:generate": "node -e \"require('node:fs').rmSync('generated/prisma', { recursive: true, force: true })\" && prisma generate",
|
||||
"prisma:format": "prisma format",
|
||||
"prisma:validate": "prisma validate",
|
||||
"prebuild": "pnpm run prisma:generate",
|
||||
"pretypecheck": "pnpm run prisma:generate",
|
||||
"pretest": "pnpm run prisma:generate",
|
||||
"data:reencrypt": "ts-node scripts/reencrypt-sensitive-data.ts",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "ts-node-dev --respawn --transpile-only src/main.ts",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import "dotenv/config";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Prisma, PrismaClient } from "../generated/prisma/client";
|
||||
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||
|
||||
type MigrationCounter = Record<
|
||||
"aiBindings" | "publicPools" | "tasks" | "attachments" | "syncOperations",
|
||||
number
|
||||
>;
|
||||
|
||||
function createEncryptionService(): DataEncryptionService {
|
||||
const configService = {
|
||||
get: (key: string) => process.env[key]
|
||||
} as ConfigService;
|
||||
|
||||
return new DataEncryptionService(configService);
|
||||
}
|
||||
|
||||
function encryptStringIfNeeded(
|
||||
value: string | null,
|
||||
dataEncryptionService: DataEncryptionService
|
||||
): string | null | undefined {
|
||||
if (value === null || dataEncryptionService.isEncryptedString(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return dataEncryptionService.encryptString(value) ?? null;
|
||||
}
|
||||
|
||||
function encryptJsonIfNeeded(
|
||||
value: Prisma.JsonValue | null,
|
||||
dataEncryptionService: DataEncryptionService
|
||||
): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && dataEncryptionService.isEncryptedString(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (dataEncryptionService.encryptJson(value as Prisma.InputJsonValue) ?? Prisma.JsonNull) as
|
||||
| Prisma.InputJsonValue
|
||||
| Prisma.NullableJsonNullValueInput;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!process.env["DATABASE_URL"]) {
|
||||
throw new Error("缺少 DATABASE_URL,无法执行敏感数据迁移");
|
||||
}
|
||||
|
||||
if (!process.env["DATA_ENCRYPTION_SECRET"]) {
|
||||
throw new Error("缺少 DATA_ENCRYPTION_SECRET,无法执行敏感数据迁移");
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({
|
||||
connectionString: process.env["DATABASE_URL"]
|
||||
})
|
||||
});
|
||||
const dataEncryptionService = createEncryptionService();
|
||||
const counter: MigrationCounter = {
|
||||
aiBindings: 0,
|
||||
publicPools: 0,
|
||||
tasks: 0,
|
||||
attachments: 0,
|
||||
syncOperations: 0
|
||||
};
|
||||
|
||||
try {
|
||||
const aiBindings = await prisma.aiProviderBinding.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
providerName: true,
|
||||
model: true,
|
||||
configId: true,
|
||||
configName: true,
|
||||
endpoint: true,
|
||||
encryptedApiKey: true
|
||||
}
|
||||
});
|
||||
|
||||
for (const binding of aiBindings) {
|
||||
const data: Prisma.AiProviderBindingUpdateInput = {};
|
||||
const providerName = encryptStringIfNeeded(binding.providerName, dataEncryptionService);
|
||||
const model = encryptStringIfNeeded(binding.model, dataEncryptionService);
|
||||
const configId = encryptStringIfNeeded(binding.configId, dataEncryptionService);
|
||||
const configName = encryptStringIfNeeded(binding.configName, dataEncryptionService);
|
||||
const endpoint = encryptStringIfNeeded(binding.endpoint, dataEncryptionService);
|
||||
const encryptedApiKey = encryptStringIfNeeded(binding.encryptedApiKey, dataEncryptionService);
|
||||
|
||||
if (providerName !== undefined) {
|
||||
data.providerName = providerName;
|
||||
}
|
||||
if (model !== undefined) {
|
||||
data.model = model;
|
||||
}
|
||||
if (configId !== undefined) {
|
||||
data.configId = configId;
|
||||
}
|
||||
if (configName !== undefined) {
|
||||
data.configName = configName;
|
||||
}
|
||||
if (endpoint !== undefined) {
|
||||
data.endpoint = endpoint;
|
||||
}
|
||||
if (encryptedApiKey !== undefined) {
|
||||
data.encryptedApiKey = encryptedApiKey;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.aiProviderBinding.update({
|
||||
where: {
|
||||
id: binding.id
|
||||
},
|
||||
data
|
||||
});
|
||||
counter.aiBindings += 1;
|
||||
}
|
||||
|
||||
const publicPools = await prisma.aiPublicPoolConfig.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
providerName: true,
|
||||
model: true,
|
||||
endpoint: true,
|
||||
encryptedApiKey: true
|
||||
}
|
||||
});
|
||||
|
||||
for (const publicPool of publicPools) {
|
||||
const data: Prisma.AiPublicPoolConfigUpdateInput = {};
|
||||
const providerName = encryptStringIfNeeded(publicPool.providerName, dataEncryptionService);
|
||||
const model = encryptStringIfNeeded(publicPool.model, dataEncryptionService);
|
||||
const endpoint = encryptStringIfNeeded(publicPool.endpoint, dataEncryptionService);
|
||||
const encryptedApiKey = encryptStringIfNeeded(
|
||||
publicPool.encryptedApiKey,
|
||||
dataEncryptionService
|
||||
);
|
||||
|
||||
if (providerName !== undefined) {
|
||||
data.providerName = providerName;
|
||||
}
|
||||
if (model !== undefined) {
|
||||
data.model = model;
|
||||
}
|
||||
if (endpoint !== undefined) {
|
||||
data.endpoint = endpoint;
|
||||
}
|
||||
if (encryptedApiKey !== undefined) {
|
||||
data.encryptedApiKey = encryptedApiKey;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.aiPublicPoolConfig.update({
|
||||
where: {
|
||||
id: publicPool.id
|
||||
},
|
||||
data
|
||||
});
|
||||
counter.publicPools += 1;
|
||||
}
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
contentJson: true,
|
||||
contentText: true
|
||||
}
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
const data: Prisma.TaskUpdateInput = {};
|
||||
const title = encryptStringIfNeeded(task.title, dataEncryptionService);
|
||||
const contentJson = encryptJsonIfNeeded(task.contentJson, dataEncryptionService);
|
||||
const contentText = encryptStringIfNeeded(task.contentText, dataEncryptionService);
|
||||
|
||||
if (title !== undefined) {
|
||||
data.title = title;
|
||||
}
|
||||
if (contentJson !== undefined) {
|
||||
data.contentJson = contentJson;
|
||||
}
|
||||
if (contentText !== undefined) {
|
||||
data.contentText = contentText;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.task.update({
|
||||
where: {
|
||||
id: task.id
|
||||
},
|
||||
data
|
||||
});
|
||||
counter.tasks += 1;
|
||||
}
|
||||
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
fileName: true,
|
||||
checksum: true
|
||||
}
|
||||
});
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const data: Prisma.AttachmentUpdateInput = {};
|
||||
const url = encryptStringIfNeeded(attachment.url, dataEncryptionService);
|
||||
const fileName = encryptStringIfNeeded(attachment.fileName, dataEncryptionService);
|
||||
const checksum = encryptStringIfNeeded(attachment.checksum, dataEncryptionService);
|
||||
|
||||
if (url !== undefined) {
|
||||
data.url = url;
|
||||
}
|
||||
if (fileName !== undefined) {
|
||||
data.fileName = fileName;
|
||||
}
|
||||
if (checksum !== undefined) {
|
||||
data.checksum = checksum;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.attachment.update({
|
||||
where: {
|
||||
id: attachment.id
|
||||
},
|
||||
data
|
||||
});
|
||||
counter.attachments += 1;
|
||||
}
|
||||
|
||||
const syncOperations = await prisma.syncOperation.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
payload: true
|
||||
}
|
||||
});
|
||||
|
||||
for (const operation of syncOperations) {
|
||||
if (operation.payload === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nextPayload: string | null = null;
|
||||
if (typeof operation.payload === "string") {
|
||||
if (dataEncryptionService.isEncryptedString(operation.payload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextPayload = dataEncryptionService.encryptString(operation.payload) ?? null;
|
||||
} else {
|
||||
nextPayload =
|
||||
dataEncryptionService.encryptString(JSON.stringify(operation.payload)) ?? null;
|
||||
}
|
||||
|
||||
if (nextPayload === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.syncOperation.update({
|
||||
where: {
|
||||
id: operation.id
|
||||
},
|
||||
data: {
|
||||
payload: nextPayload
|
||||
}
|
||||
});
|
||||
counter.syncOperations += 1;
|
||||
}
|
||||
|
||||
console.log("敏感数据迁移完成");
|
||||
console.log(JSON.stringify(counter, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "未知错误";
|
||||
console.error(`敏感数据迁移失败:${message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import request from "supertest";
|
||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import {
|
||||
AiChannel,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
AiRouteFailureError
|
||||
} from "../src/ai/ai.types";
|
||||
import { PrismaService } from "../src/prisma/prisma.service";
|
||||
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||
|
||||
type AiUsageLogRecord = {
|
||||
id: string;
|
||||
@@ -297,6 +299,10 @@ class InMemoryAiPrismaService {
|
||||
return [...this.usageLogs];
|
||||
}
|
||||
|
||||
getBindings(): AiProviderBinding[] {
|
||||
return [...this.bindings];
|
||||
}
|
||||
|
||||
seedTask(task: AiTaskRecord): void {
|
||||
this.tasks.push(task);
|
||||
}
|
||||
@@ -401,10 +407,18 @@ describe("AiController (integration)", () => {
|
||||
controllers: [AiController],
|
||||
providers: [
|
||||
AiService,
|
||||
DataEncryptionService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: prismaService
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: (key: string) =>
|
||||
key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: AiProviderRegistryService,
|
||||
useValue: {
|
||||
@@ -466,6 +480,11 @@ describe("AiController (integration)", () => {
|
||||
maskedApiKey: "abk_***34",
|
||||
isEnabled: true
|
||||
});
|
||||
|
||||
const storedBinding = prismaService.getBindings()[0];
|
||||
expect(storedBinding?.providerName).not.toBe("astrbot-main");
|
||||
expect(storedBinding?.endpoint).not.toBe("http://127.0.0.1:6185");
|
||||
expect(storedBinding?.encryptedApiKey).not.toBe("abk_secret_1234");
|
||||
});
|
||||
|
||||
it("should hide public pool endpoint from user bindings response", async () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import request from "supertest";
|
||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PrismaService } from "../src/prisma/prisma.service";
|
||||
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||
import { SyncController } from "../src/sync/sync.controller";
|
||||
import { SyncService } from "../src/sync/sync.service";
|
||||
|
||||
@@ -159,6 +161,10 @@ class InMemoryPrismaService {
|
||||
return this.syncOperations.length;
|
||||
}
|
||||
|
||||
getRawOperationById(opId: string): SyncOperationRecord | undefined {
|
||||
return this.syncOperations.find((operation) => operation.opId === opId);
|
||||
}
|
||||
|
||||
seedOperations(records: Array<Omit<SyncOperationRecord, "id">>): void {
|
||||
for (const record of records) {
|
||||
this.syncOperations.push({
|
||||
@@ -196,7 +202,18 @@ describe("SyncController (integration)", () => {
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, { provide: PrismaService, useValue: prismaService }]
|
||||
providers: [
|
||||
SyncService,
|
||||
DataEncryptionService,
|
||||
{ provide: PrismaService, useValue: prismaService },
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: (key: string) =>
|
||||
key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined
|
||||
}
|
||||
}
|
||||
]
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
@@ -258,6 +275,9 @@ describe("SyncController (integration)", () => {
|
||||
})
|
||||
]);
|
||||
expect(prismaService.getOperationCount()).toBe(2);
|
||||
expect(prismaService.getRawOperationById("op-create-1")?.payload).not.toBe(
|
||||
'{"title":"浠诲姟涓€"}'
|
||||
);
|
||||
|
||||
const secondResponse = await request(app.getHttpServer())
|
||||
.post("/sync/push")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import request from "supertest";
|
||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PrismaService } from "../src/prisma/prisma.service";
|
||||
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||
import { TaskController } from "../src/task/task.controller";
|
||||
import { TaskService } from "../src/task/task.service";
|
||||
import { TaskPriority, TaskStatus } from "../generated/prisma/client";
|
||||
@@ -355,6 +357,10 @@ class InMemoryPrismaService {
|
||||
return runner(this);
|
||||
}
|
||||
|
||||
getRawTaskById(taskId: string): TaskRecord | undefined {
|
||||
return this.tasks.find((task) => task.id === taskId);
|
||||
}
|
||||
|
||||
private toTaskWithTags(
|
||||
task: TaskRecord
|
||||
): TaskRecord & { taskTags: Array<{ tag: { name: string } }> } {
|
||||
@@ -390,7 +396,15 @@ describe("TaskController (integration)", () => {
|
||||
controllers: [TaskController],
|
||||
providers: [
|
||||
TaskService,
|
||||
{ provide: PrismaService, useValue: prismaService as unknown as PrismaService }
|
||||
DataEncryptionService,
|
||||
{ provide: PrismaService, useValue: prismaService as unknown as PrismaService },
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: (key: string) =>
|
||||
key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined
|
||||
}
|
||||
}
|
||||
]
|
||||
}).compile();
|
||||
|
||||
@@ -425,6 +439,9 @@ describe("TaskController (integration)", () => {
|
||||
expect(createResponse.body.id).toBeDefined();
|
||||
expect(createResponse.body.tags).toEqual(["工作", "会议"]);
|
||||
const taskId = createResponse.body.id as string;
|
||||
const rawCreatedTask = prismaService.getRawTaskById(taskId);
|
||||
expect(rawCreatedTask?.title).not.toBe("准备周会");
|
||||
expect(rawCreatedTask?.contentText).not.toBe("整理本周进度");
|
||||
|
||||
const listResponse = await request(app.getHttpServer())
|
||||
.get("/tasks")
|
||||
|
||||
Reference in New Issue
Block a user