Commit 889fd81f authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/cron-retention-and-env-config-sync' into 'develop'

feat(system-config): sync configs with .env and add 7-day retention cron jobs

See merge request !19
parents 444c7509 adc75aa5
......@@ -12,6 +12,7 @@ async function main() {
await prisma.systemConfig.upsert({
where: { key: item.key },
update: {
value: item.value as any,
description: item.description ?? null,
category: item.category,
isPublic: item.isPublic,
......
export const CRON_JOB_NAMES = {
CLEANUP_AUDIT_LOGS: "cleanup-audit-logs",
CLEANUP_EXPORTS: "cleanup-exports",
CLEANUP_UNCONFIRMED_UPLOADS: "cleanup-unconfirmed-uploads",
CLEANUP_EXPIRED_TOKENS: "cleanup-expired-tokens",
DAILY_SUMMARY_DIGEST: "daily-summary-digest",
......@@ -24,7 +25,9 @@ export const CRON_QUEUE_NAME = "cron-scheduler-queue" as const;
export const CRON_SYSTEM_CONFIG_KEY = "CRON_JOB_STATUSES" as const;
export const DEFAULT_AUDIT_LOG_RETENTION_DAYS = 30;
export const DEFAULT_AUDIT_LOG_RETENTION_DAYS = 7;
export const DEFAULT_EXPORT_RETENTION_DAYS = 7;
export const DEFAULT_UNCONFIRMED_UPLOAD_MAX_AGE_HOURS = 24;
......@@ -37,13 +40,20 @@ export interface CronScheduleConfig {
export const DEFAULT_CRON_SCHEDULES: Record<CronJobName, CronScheduleConfig> = {
[CRON_JOB_NAMES.CLEANUP_AUDIT_LOGS]: {
cron: "0 2 * * *",
description: "Dọn dẹp các bản ghi nhật ký kiểm toán cũ hơn số ngày quy định",
description: "Dọn dẹp các bản ghi nhật ký kiểm toán (audit logs) cũ hơn 7 ngày",
defaultParams: {
retentionDays: DEFAULT_AUDIT_LOG_RETENTION_DAYS,
},
},
[CRON_JOB_NAMES.CLEANUP_UNCONFIRMED_UPLOADS]: {
[CRON_JOB_NAMES.CLEANUP_EXPORTS]: {
cron: "0 3 * * *",
description: "Dọn dẹp các tệp xuất dữ liệu (exports) và bản ghi hết hạn hoặc cũ hơn 7 ngày",
defaultParams: {
retentionDays: DEFAULT_EXPORT_RETENTION_DAYS,
},
},
[CRON_JOB_NAMES.CLEANUP_UNCONFIRMED_UPLOADS]: {
cron: "0 4 * * *",
description: "Quét và dọn dẹp các tệp tin tải lên mồ côi hoặc xuất file tạm quá hạn",
defaultParams: {
maxAgeHours: DEFAULT_UNCONFIRMED_UPLOAD_MAX_AGE_HOURS,
......
......@@ -12,6 +12,142 @@
}
],
"paths": {
"/health/liveness": {
"get": {
"description": "Endpoint kiểm tra xem ứng dụng còn phản hồi hay không (dành cho Kubernetes / Docker health check).",
"responses": {
"200": {
"description": "Ứng dụng hoạt động bình thường",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"uptimeSeconds": {
"type": "integer",
"example": 3600
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"nodeVersion": {
"type": "string",
"example": "v22.14.0"
}
}
}
}
}
}
},
"tags": [
"Health"
],
"summary": "Kiểm tra liveness của service"
}
},
"/health/readiness": {
"get": {
"description": "Endpoint kiểm tra kết nối tới cơ sở dữ liệu PostgreSQL và hàng đợi Redis.",
"responses": {
"200": {
"description": "Hệ thống sẵn sàng tiếp nhận request",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ready"
},
"checks": {
"type": "object",
"properties": {
"database": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
},
"latencyMs": {
"type": "integer",
"example": 5
}
}
},
"redis": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
},
"latencyMs": {
"type": "integer",
"example": 2
}
}
}
}
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
}
}
}
},
"503": {
"description": "Hệ thống chưa sẵn sàng, dịch vụ phụ trợ gặp lỗi"
}
},
"tags": [
"Health"
],
"summary": "Kiểm tra readiness của service (PostgreSQL & Redis)"
}
},
"/health/metrics": {
"get": {
"description": "Trả về thông tin chi tiết về bộ nhớ RAM tiến trình, thời gian uptime và trạng thái các hàng đợi BullMQ.",
"responses": {
"200": {
"description": "Lấy metrics thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"memory": {
"type": "object"
},
"uptime": {
"type": "number"
},
"queues": {
"type": "object"
}
}
}
}
}
}
},
"tags": [
"Health"
],
"summary": "Xem thông số metrics hệ thống và hàng đợi"
}
},
"/auth/login": {
"post": {
"description": "Xác thực email và mật khẩu để nhận Access Token và Refresh Token.",
......@@ -2095,6 +2231,16 @@
"summary": "Bật/tắt nhanh Feature Flag"
}
},
"/system/configs/sync-env": {
"post": {
"description": "",
"responses": {
"default": {
"description": ""
}
}
}
},
"/cron/jobs": {
"get": {
"description": "",
......@@ -5150,142 +5296,6 @@
],
"summary": "Xóa template trích xuất"
}
},
"/health/liveness": {
"get": {
"tags": [
"Health"
],
"summary": "Kiểm tra liveness của service",
"description": "Endpoint kiểm tra xem ứng dụng còn phản hồi hay không (dành cho Kubernetes / Docker health check).",
"responses": {
"200": {
"description": "Ứng dụng hoạt động bình thường",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"uptimeSeconds": {
"type": "integer",
"example": 3600
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"nodeVersion": {
"type": "string",
"example": "v22.14.0"
}
}
}
}
}
}
}
}
},
"/health/readiness": {
"get": {
"tags": [
"Health"
],
"summary": "Kiểm tra readiness của service (PostgreSQL & Redis)",
"description": "Endpoint kiểm tra kết nối tới cơ sở dữ liệu PostgreSQL và hàng đợi Redis.",
"responses": {
"200": {
"description": "Hệ thống sẵn sàng tiếp nhận request",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ready"
},
"checks": {
"type": "object",
"properties": {
"database": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
},
"latencyMs": {
"type": "integer",
"example": 5
}
}
},
"redis": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
},
"latencyMs": {
"type": "integer",
"example": 2
}
}
}
}
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
}
}
}
},
"503": {
"description": "Hệ thống chưa sẵn sàng, dịch vụ phụ trợ gặp lỗi"
}
}
}
},
"/health/metrics": {
"get": {
"tags": [
"Health"
],
"summary": "Xem thông số metrics hệ thống và hàng đợi",
"description": "Trả về thông tin chi tiết về bộ nhớ RAM tiến trình, thời gian uptime và trạng thái các hàng đợi BullMQ.",
"responses": {
"200": {
"description": "Lấy metrics thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"memory": {
"type": "object"
},
"uptime": {
"type": "number"
},
"queues": {
"type": "object"
}
}
}
}
}
}
}
}
}
},
"components": {
......
......@@ -10,6 +10,7 @@ export interface CreateCrawlExportDto {
filePath: string;
fileSize?: number;
mimeType?: string;
expiredAt?: Date | null;
}
export interface UpdateCrawlExportDto {
......
......@@ -16,8 +16,13 @@ describe("CronService", () => {
let mockStorageService: jest.Mocked<IStorageService>;
let mockMailService: jest.Mocked<MailService>;
let mockQueueService: jest.Mocked<CronQueueService>;
let mockConfigService: { get: jest.Mock };
beforeEach(() => {
mockConfigService = {
get: jest.fn().mockImplementation((_key: string, defaultVal: unknown) => Promise.resolve(defaultVal)),
};
mockRepository = {
getJobStatuses: jest.fn().mockResolvedValue({
[CRON_JOB_NAMES.CLEANUP_AUDIT_LOGS]: true,
......@@ -43,6 +48,10 @@ describe("CronService", () => {
deletedCount: 2,
filePaths: ["exports/test1.zip", "exports/test2.zip"],
}),
cleanupOldExports: jest.fn().mockResolvedValue({
deletedCount: 3,
filePaths: ["exports/old1.zip", "exports/old2.zip", "exports/old3.zip"],
}),
getActiveAvatarUrls: jest.fn().mockResolvedValue([]),
getDigestStats: jest.fn().mockResolvedValue({
newUsers: 5,
......@@ -85,6 +94,7 @@ describe("CronService", () => {
mockStorageService,
mockMailService,
mockQueueService,
mockConfigService as any,
);
});
......@@ -180,6 +190,25 @@ describe("CronService", () => {
);
});
it("should execute cleanup-exports and delete old export files", async () => {
const result = await cronService.triggerJob(
CRON_JOB_NAMES.CLEANUP_EXPORTS,
{ retentionDays: 7 },
);
expect(result.success).toBe(true);
expect(mockRepository.cleanupOldExports).toHaveBeenCalled();
expect(mockStorageService.deleteFile).toHaveBeenCalledWith("exports/old1.zip");
expect(mockStorageService.deleteFile).toHaveBeenCalledWith("exports/old2.zip");
expect(mockStorageService.deleteFile).toHaveBeenCalledWith("exports/old3.zip");
expect(result.data).toEqual(
expect.objectContaining({
cleanedExportsCount: 3,
retentionDays: 7,
}),
);
});
it("should execute cleanup-unconfirmed-uploads and delete expired export files", async () => {
const result = await cronService.triggerJob(
CRON_JOB_NAMES.CLEANUP_UNCONFIRMED_UPLOADS,
......
......@@ -194,6 +194,57 @@ export class CronRepository {
};
}
/**
* Dọn dẹp các tệp xuất dữ liệu CrawlExport cũ hơn số ngày quy định (createdAt < cutoffDate)
* hoặc đã hết hạn (expiredAt < now)
*/
async cleanupOldExports(
cutoffDate: Date,
now: Date = new Date(),
): Promise<{ deletedCount: number; filePaths: string[] }> {
const expiredExports = await prisma.crawlExport.findMany({
where: {
OR: [
{
expiredAt: {
not: null,
lt: now,
},
},
{
createdAt: {
lt: cutoffDate,
},
},
],
},
select: {
id: true,
filePath: true,
},
});
if (expiredExports.length === 0) {
return { deletedCount: 0, filePaths: [] };
}
const ids = expiredExports.map((e) => e.id);
const filePaths = expiredExports.map((e) => e.filePath).filter(Boolean);
await prisma.crawlExport.deleteMany({
where: {
id: {
in: ids,
},
},
});
return {
deletedCount: ids.length,
filePaths,
};
}
/**
* Lấy danh sách đường dẫn avatar đang được người dùng sử dụng
*/
......
......@@ -10,6 +10,7 @@ import {
CRON_JOB_STATUS,
CronJobName,
DEFAULT_AUDIT_LOG_RETENTION_DAYS,
DEFAULT_EXPORT_RETENTION_DAYS,
DEFAULT_CRON_SCHEDULES,
DEFAULT_UNCONFIRMED_UPLOAD_MAX_AGE_HOURS,
} from "../../common/constants/cron.constant";
......@@ -26,6 +27,10 @@ import {
getZonedDateParts,
createUtcDateFromZonedParts,
} from "../../common/helpers/schedule-calculator.helper";
import {
SystemConfigService,
systemConfigService,
} from "../system-config/system-config.service";
export class CronService {
constructor(
......@@ -33,6 +38,7 @@ export class CronService {
private readonly storageService: IStorageService = StorageFactory.getStorageService(),
private readonly mailService: MailService = new MailService(),
private readonly queueService: CronQueueService = cronQueue,
private readonly configService: SystemConfigService = systemConfigService,
) {}
/**
......@@ -179,6 +185,13 @@ export class CronService {
break;
}
case CRON_JOB_NAMES.CLEANUP_EXPORTS: {
executionData = await this.executeCleanupExports(
params as { retentionDays?: number } | undefined,
);
break;
}
case CRON_JOB_NAMES.CLEANUP_UNCONFIRMED_UPLOADS: {
executionData = await this.executeCleanupUnconfirmedUploads(
params as { maxAgeHours?: number } | undefined,
......@@ -262,8 +275,23 @@ export class CronService {
retentionDays: number;
cutoffDate: string;
}> {
const retentionDays =
params?.retentionDays ?? DEFAULT_AUDIT_LOG_RETENTION_DAYS;
const isFeatureEnabled = await this.configService.get<boolean>(
"feature.cron.cleanup_audit_logs.enabled",
true,
);
if (!isFeatureEnabled && params?.retentionDays === undefined) {
return {
deletedCount: 0,
retentionDays: 0,
cutoffDate: new Date().toISOString(),
};
}
const defaultRetention = await this.configService.get<number>(
"retention.audit_logs_days",
DEFAULT_AUDIT_LOG_RETENTION_DAYS,
);
const retentionDays = params?.retentionDays ?? defaultRetention;
const cutoffDate = new Date(
Date.now() - retentionDays * 24 * 60 * 60 * 1000,
);
......@@ -277,6 +305,56 @@ export class CronService {
};
}
/**
* Tác vụ: Xóa các bản ghi CrawlExport và tệp tin xuất dữ liệu cũ hơn số ngày quy định
*/
async executeCleanupExports(params?: { retentionDays?: number }): Promise<{
cleanedExportsCount: number;
retentionDays: number;
cutoffDate: string;
}> {
const isFeatureEnabled = await this.configService.get<boolean>(
"feature.cron.cleanup_exports.enabled",
true,
);
if (!isFeatureEnabled && params?.retentionDays === undefined) {
return {
cleanedExportsCount: 0,
retentionDays: 0,
cutoffDate: new Date().toISOString(),
};
}
const defaultRetention = await this.configService.get<number>(
"retention.exports_days",
DEFAULT_EXPORT_RETENTION_DAYS,
);
const retentionDays = params?.retentionDays ?? defaultRetention;
const cutoffDate = new Date(
Date.now() - retentionDays * 24 * 60 * 60 * 1000,
);
const { deletedCount, filePaths } =
await this.repository.cleanupOldExports(cutoffDate, new Date());
for (const filePath of filePaths) {
try {
await this.storageService.deleteFile(filePath);
} catch (fileErr) {
console.warn(
`[Cron Cleanup] Could not delete export file ${filePath}:`,
fileErr,
);
}
}
return {
cleanedExportsCount: deletedCount,
retentionDays,
cutoffDate: cutoffDate.toISOString(),
};
}
/**
* Tác vụ: Quét và dọn dẹp các tệp tin tải lên mồ côi và export đã hết hạn
*/
......
import { CrawlJob, CrawlPage } from "@prisma/client";
import { ExportService } from "../export.service";
import { JsonExportService } from "../json-export.service";
import { systemConfigService } from "../../system-config/system-config.service";
describe("ExportService", () => {
beforeEach(() => {
jest.spyOn(systemConfigService, "get").mockResolvedValue(7);
});
afterEach(() => {
jest.restoreAllMocks();
});
......
......@@ -14,6 +14,8 @@ import {
ExportType,
} from "../../common/constants/export-type.constant";
import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { DEFAULT_EXPORT_RETENTION_DAYS } from "../../common/constants/cron.constant";
import { systemConfigService } from "../system-config/system-config.service";
const EXPORT_SERVICES: Record<ExportType, new () => IExportService> = {
[EXPORT_TYPE.JSON]: JsonExportService,
......@@ -46,6 +48,15 @@ export class ExportService {
const service = new ServiceClass();
const result = await service.export(fullJob);
const retentionDays = await systemConfigService.get<number>(
"retention.exports_days",
DEFAULT_EXPORT_RETENTION_DAYS,
);
const expiredAt = new Date(
Date.now() + retentionDays * 24 * 60 * 60 * 1000,
);
const exportRecord = await this.exportRepository.create({
jobId: job.id,
exportType,
......@@ -53,6 +64,7 @@ export class ExportService {
filePath: result.filePath,
fileSize: result.fileSize,
mimeType: result.mimeType,
expiredAt,
});
const completedExportRecord = await this.exportRepository.update(
......
......@@ -146,4 +146,21 @@ export class SystemConfigController {
next(error);
}
};
syncFromEnv = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const result = await this.service.syncFromEnv();
res.json({
success: true,
data: result,
message: "Đã đồng bộ toàn bộ giá trị cấu hình từ .env thành công",
});
} catch (error) {
next(error);
}
};
}
......@@ -104,14 +104,20 @@ export class SystemConfigRepository {
});
}
async ensureDefault(item: DefaultSystemConfigItem) {
async ensureDefault(item: DefaultSystemConfigItem, syncValue = true) {
const updatePayload: Prisma.SystemConfigUpdateInput = {
description: item.description ?? null,
category: item.category,
isPublic: item.isPublic,
};
if (syncValue) {
updatePayload.value = item.value as Prisma.InputJsonValue;
}
return prisma.systemConfig.upsert({
where: { key: item.key },
update: {
description: item.description ?? null,
category: item.category,
isPublic: item.isPublic,
},
update: updatePayload,
create: {
key: item.key,
value: item.value as Prisma.InputJsonValue,
......
......@@ -76,4 +76,12 @@ router.delete(
controller.delete,
);
// 8. POST /api/v1/system/configs/sync-env (Sync configs from .env into DB)
router.post(
"/configs/sync-env",
authMiddleware,
requirePermission(PERMISSIONS.SYSTEM_CONFIG_MANAGE),
controller.syncFromEnv,
);
export default router;
......@@ -388,12 +388,12 @@ export class SystemConfigService {
}
/**
* Khởi tạo cấu hình mặc định nếu chưa tồn tại trong Database
* Khởi tạo và đồng bộ cấu hình mặc định từ biến môi trường vào Database
*/
async ensureDefaultConfigs(): Promise<void> {
async ensureDefaultConfigs(syncValues = true): Promise<void> {
for (const item of DEFAULT_SYSTEM_CONFIGS) {
try {
await this.repository.ensureDefault(item);
await this.repository.ensureDefault(item, syncValues);
} catch (err) {
console.warn(
`[SystemConfig] ensureDefault error for ${item.key}:`,
......@@ -403,6 +403,24 @@ export class SystemConfigService {
}
this.clearLocalCache();
}
/**
* Đồng bộ toàn bộ giá trị cấu hình từ biến môi trường (.env) vào Database
*/
async syncFromEnv(): Promise<{ syncedCount: number; keys: string[] }> {
const keys: string[] = [];
for (const item of DEFAULT_SYSTEM_CONFIGS) {
try {
await this.repository.ensureDefault(item, true);
keys.push(item.key);
} catch (err) {
console.warn(`[SystemConfig] syncFromEnv error for ${item.key}:`, err);
}
}
this.clearLocalCache();
await this.publishInvalidation();
return { syncedCount: keys.length, keys };
}
}
export const systemConfigService = new SystemConfigService();
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment