Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
U
upgrade-data-crawler-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
upgrade-data-crawler-be
Commits
0b96ce87
Commit
0b96ce87
authored
Sep 08, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(system-config): implement dynamic configuration module and feature flags
parent
ffc94a49
Changes
29
Hide whitespace changes
Inline
Side-by-side
Showing
29 changed files
with
2761 additions
and
60 deletions
+2761
-60
package.json
package.json
+1
-0
migration.sql
...migrations/20260908093359_add_system_config/migration.sql
+22
-0
schema.prisma
prisma/schema.prisma
+15
-0
seed.ts
prisma/seed.ts
+23
-0
sync-system-configs.ts
scripts/sync-system-configs.ts
+65
-0
app.ts
src/app.ts
+2
-1
audit-action.constant.ts
src/common/constants/audit-action.constant.ts
+4
-0
index.ts
src/common/constants/index.ts
+1
-0
permission.constant.ts
src/common/constants/permission.constant.ts
+24
-0
system-config.constant.ts
src/common/constants/system-config.constant.ts
+340
-0
redis-pubsub.ts
src/common/redis/redis-pubsub.ts
+57
-0
swagger-paths.ts
src/docs/swagger-paths.ts
+247
-0
swagger.json
src/docs/swagger.json
+451
-0
swagger.ts
src/docs/swagger.ts
+44
-0
maintenance.middleware.ts
src/middlewares/maintenance.middleware.ts
+53
-0
rate-limit.middleware.ts
src/middlewares/rate-limit.middleware.ts
+6
-1
auth.service.ts
src/modules/auth/auth.service.ts
+13
-0
firecrawl.client.ts
src/modules/firecrawl/firecrawl.client.ts
+38
-0
system-config.service.test.ts
...les/system-config/__tests__/system-config.service.test.ts
+373
-0
system-config.controller.ts
src/modules/system-config/system-config.controller.ts
+149
-0
system-config.dto.ts
src/modules/system-config/system-config.dto.ts
+51
-0
system-config.repository.ts
src/modules/system-config/system-config.repository.ts
+124
-0
system-config.route.ts
src/modules/system-config/system-config.route.ts
+79
-0
system-config.service.ts
src/modules/system-config/system-config.service.ts
+408
-0
system-config.validation.ts
src/modules/system-config/system-config.validation.ts
+70
-0
user.repository.ts
src/modules/users/user.repository.ts
+18
-6
crawl.worker.processor.ts
src/queues/crawl.worker.processor.ts
+69
-52
index.ts
src/routes/index.ts
+2
-0
server.ts
src/server.ts
+12
-0
No files found.
package.json
View file @
0b96ce87
...
@@ -18,6 +18,7 @@
...
@@ -18,6 +18,7 @@
"db:migrate:reset"
:
"node scripts/prisma-run.js migrate reset"
,
"db:migrate:reset"
:
"node scripts/prisma-run.js migrate reset"
,
"db:migrate:status"
:
"node scripts/prisma-run.js migrate status"
,
"db:migrate:status"
:
"node scripts/prisma-run.js migrate status"
,
"db:seed"
:
"node scripts/prisma-run.js db seed -- --tsx prisma/seed.ts"
,
"db:seed"
:
"node scripts/prisma-run.js db seed -- --tsx prisma/seed.ts"
,
"config:sync"
:
"tsx scripts/sync-system-configs.ts"
,
"lint"
:
"eslint ."
,
"lint"
:
"eslint ."
,
"format"
:
"prettier --write ."
,
"format"
:
"prettier --write ."
,
"test"
:
"jest"
"test"
:
"jest"
...
...
prisma/migrations/20260908093359_add_system_config/migration.sql
0 → 100644
View file @
0b96ce87
-- CreateTable
CREATE
TABLE
"system_configs"
(
"id"
UUID
NOT
NULL
,
"key"
TEXT
NOT
NULL
,
"value"
JSONB
NOT
NULL
,
"description"
TEXT
,
"category"
TEXT
NOT
NULL
DEFAULT
'GENERAL'
,
"is_public"
BOOLEAN
NOT
NULL
DEFAULT
false
,
"created_at"
TIMESTAMP
(
3
)
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
,
"updated_at"
TIMESTAMP
(
3
)
NOT
NULL
,
CONSTRAINT
"system_configs_pkey"
PRIMARY
KEY
(
"id"
)
);
-- CreateIndex
CREATE
UNIQUE
INDEX
"system_configs_key_key"
ON
"system_configs"
(
"key"
);
-- CreateIndex
CREATE
INDEX
"system_configs_category_idx"
ON
"system_configs"
(
"category"
);
-- CreateIndex
CREATE
INDEX
"system_configs_is_public_idx"
ON
"system_configs"
(
"is_public"
);
prisma/schema.prisma
View file @
0b96ce87
...
@@ -468,3 +468,18 @@ model RolePermission {
...
@@ -468,3 +468,18 @@ model RolePermission {
@@
map
(
"role_permissions"
)
@@
map
(
"role_permissions"
)
}
}
model
SystemConfig
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
key
String
@
unique
value
Json
description
String
?
category
String
@
default
(
"GENERAL"
)
isPublic
Boolean
@
default
(
false
)
@
map
(
"is_public"
)
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
@@
index
([
category
])
@@
index
([
isPublic
])
@@
map
(
"system_configs"
)
}
prisma/seed.ts
View file @
0b96ce87
...
@@ -17,6 +17,7 @@ import {
...
@@ -17,6 +17,7 @@ import {
SYSTEM_PERMISSIONS_CATALOG
,
SYSTEM_PERMISSIONS_CATALOG
,
SYSTEM_ROLE_DEFAULT_PERMISSIONS
,
SYSTEM_ROLE_DEFAULT_PERMISSIONS
,
}
from
"../src/common/constants/permission.constant"
;
}
from
"../src/common/constants/permission.constant"
;
import
{
DEFAULT_SYSTEM_CONFIGS
}
from
"../src/common/constants/system-config.constant"
;
const
prisma
=
new
PrismaClient
();
const
prisma
=
new
PrismaClient
();
...
@@ -597,10 +598,32 @@ async function seedCrawlJobsAndPages(userId: string) {
...
@@ -597,10 +598,32 @@ async function seedCrawlJobsAndPages(userId: string) {
});
});
}
}
async
function
seedSystemConfigs
():
Promise
<
void
>
{
console
.
log
(
"Seeding default system configs..."
);
for
(
const
item
of
DEFAULT_SYSTEM_CONFIGS
)
{
await
prisma
.
systemConfig
.
upsert
({
where
:
{
key
:
item
.
key
},
update
:
{
description
:
item
.
description
??
null
,
category
:
item
.
category
,
isPublic
:
item
.
isPublic
,
},
create
:
{
key
:
item
.
key
,
value
:
item
.
value
as
any
,
description
:
item
.
description
??
null
,
category
:
item
.
category
,
isPublic
:
item
.
isPublic
,
},
});
}
}
async
function
main
()
{
async
function
main
()
{
const
permissionMap
=
await
seedPermissions
();
const
permissionMap
=
await
seedPermissions
();
const
roleMap
=
await
seedRoles
(
permissionMap
);
const
roleMap
=
await
seedRoles
(
permissionMap
);
await
seedUsers
(
roleMap
);
await
seedUsers
(
roleMap
);
await
seedSystemConfigs
();
const
crawlerUser
=
await
prisma
.
user
.
findUnique
({
const
crawlerUser
=
await
prisma
.
user
.
findUnique
({
where
:
{
email
:
"crawl@crawl.local"
},
where
:
{
email
:
"crawl@crawl.local"
},
...
...
scripts/sync-system-configs.ts
0 → 100644
View file @
0b96ce87
import
"dotenv/config"
;
import
{
PrismaClient
}
from
"@prisma/client"
;
import
{
DEFAULT_SYSTEM_CONFIGS
}
from
"../src/common/constants/system-config.constant"
;
const
prisma
=
new
PrismaClient
();
async
function
main
()
{
console
.
log
(
`Synchronizing
${
DEFAULT_SYSTEM_CONFIGS
.
length
}
system configs to database...`
);
let
upserted
=
0
;
for
(
const
item
of
DEFAULT_SYSTEM_CONFIGS
)
{
await
prisma
.
systemConfig
.
upsert
({
where
:
{
key
:
item
.
key
},
update
:
{
description
:
item
.
description
??
null
,
category
:
item
.
category
,
isPublic
:
item
.
isPublic
,
},
create
:
{
key
:
item
.
key
,
value
:
item
.
value
as
any
,
description
:
item
.
description
??
null
,
category
:
item
.
category
,
isPublic
:
item
.
isPublic
,
},
});
upserted
++
;
console
.
log
(
`[✔] Upserted:
${
item
.
key
}
(
${
item
.
category
}
, public:
${
item
.
isPublic
}
)`
);
}
const
allowedKeys
=
new
Set
(
DEFAULT_SYSTEM_CONFIGS
.
map
((
c
)
=>
c
.
key
));
const
deleteResult
=
await
prisma
.
systemConfig
.
deleteMany
({
where
:
{
key
:
{
notIn
:
Array
.
from
(
allowedKeys
),
},
},
});
if
(
deleteResult
.
count
>
0
)
{
console
.
log
(
`[x] Cleaned up
${
deleteResult
.
count
}
obsolete/sensitive keys from database.`
);
}
const
allConfigs
=
await
prisma
.
systemConfig
.
findMany
({
orderBy
:
[{
category
:
"asc"
},
{
key
:
"asc"
}],
select
:
{
key
:
true
,
category
:
true
,
isPublic
:
true
,
value
:
true
,
description
:
true
},
});
console
.
log
(
`\n============================ SUMMARY ============================`
);
console
.
log
(
`Total configs in DB:
${
allConfigs
.
length
}
`
);
console
.
log
(
`Upserted configs:
${
upserted
}
`
);
const
categories
=
Array
.
from
(
new
Set
(
allConfigs
.
map
((
c
)
=>
c
.
category
)));
for
(
const
cat
of
categories
)
{
const
inCat
=
allConfigs
.
filter
((
c
)
=>
c
.
category
===
cat
);
console
.
log
(
`Category [
${
cat
}
]:
${
inCat
.
length
}
configs`
);
}
}
main
()
.
catch
((
err
)
=>
{
console
.
error
(
"Sync error:"
,
err
);
process
.
exit
(
1
);
})
.
finally
(
async
()
=>
{
await
prisma
.
$disconnect
();
});
src/app.ts
View file @
0b96ce87
...
@@ -12,6 +12,7 @@ import routes from "./routes";
...
@@ -12,6 +12,7 @@ import routes from "./routes";
import
swaggerDocument
from
"./docs/swagger.json"
;
import
swaggerDocument
from
"./docs/swagger.json"
;
import
healthRoute
from
"./modules/health/health.route"
;
import
healthRoute
from
"./modules/health/health.route"
;
import
{
rateLimitMiddleware
}
from
"./middlewares/rate-limit.middleware"
;
import
{
rateLimitMiddleware
}
from
"./middlewares/rate-limit.middleware"
;
import
{
maintenanceMiddleware
}
from
"./middlewares/maintenance.middleware"
;
import
{
envConfig
}
from
"./config/env.config"
;
import
{
envConfig
}
from
"./config/env.config"
;
import
{
parseTrustProxy
}
from
"./common/helpers/proxy.helper"
;
import
{
parseTrustProxy
}
from
"./common/helpers/proxy.helper"
;
...
@@ -46,7 +47,7 @@ app.use(express.urlencoded({ extended: true }));
...
@@ -46,7 +47,7 @@ app.use(express.urlencoded({ extended: true }));
app
.
use
(
"/health"
,
healthRoute
);
app
.
use
(
"/health"
,
healthRoute
);
app
.
use
(
"/api-docs"
,
swaggerUi
.
serve
,
swaggerUi
.
setup
(
swaggerDocument
));
app
.
use
(
"/api-docs"
,
swaggerUi
.
serve
,
swaggerUi
.
setup
(
swaggerDocument
));
app
.
use
(
"/api/v1"
,
rateLimitMiddleware
,
routes
);
app
.
use
(
"/api/v1"
,
rateLimitMiddleware
,
maintenanceMiddleware
,
routes
);
app
.
use
(
notFoundMiddleware
);
app
.
use
(
notFoundMiddleware
);
app
.
use
(
errorMiddleware
);
app
.
use
(
errorMiddleware
);
...
...
src/common/constants/audit-action.constant.ts
View file @
0b96ce87
...
@@ -32,6 +32,10 @@ export const AUDIT_ACTIONS = {
...
@@ -32,6 +32,10 @@ export const AUDIT_ACTIONS = {
PERMISSION_REVOKED
:
"PERMISSION_REVOKED"
,
PERMISSION_REVOKED
:
"PERMISSION_REVOKED"
,
SUPER_ADMIN_ASSIGN_ATTEMPT
:
"SUPER_ADMIN_ASSIGN_ATTEMPT"
,
SUPER_ADMIN_ASSIGN_ATTEMPT
:
"SUPER_ADMIN_ASSIGN_ATTEMPT"
,
PRIVILEGE_ESCALATION_BLOCKED
:
"PRIVILEGE_ESCALATION_BLOCKED"
,
PRIVILEGE_ESCALATION_BLOCKED
:
"PRIVILEGE_ESCALATION_BLOCKED"
,
SYSTEM_CONFIG_CREATED
:
"SYSTEM_CONFIG_CREATED"
,
SYSTEM_CONFIG_UPDATED
:
"SYSTEM_CONFIG_UPDATED"
,
SYSTEM_CONFIG_TOGGLED
:
"SYSTEM_CONFIG_TOGGLED"
,
SYSTEM_CONFIG_DELETED
:
"SYSTEM_CONFIG_DELETED"
,
}
as
const
;
}
as
const
;
export
type
AuditAction
=
(
typeof
AUDIT_ACTIONS
)[
keyof
typeof
AUDIT_ACTIONS
];
export
type
AuditAction
=
(
typeof
AUDIT_ACTIONS
)[
keyof
typeof
AUDIT_ACTIONS
];
src/common/constants/index.ts
View file @
0b96ce87
...
@@ -12,3 +12,4 @@ export * from "./crawl-page-status.constant";
...
@@ -12,3 +12,4 @@ export * from "./crawl-page-status.constant";
export
*
from
"./webhook.constant"
;
export
*
from
"./webhook.constant"
;
export
*
from
"./system-role.constant"
;
export
*
from
"./system-role.constant"
;
export
*
from
"./permission.constant"
;
export
*
from
"./permission.constant"
;
export
*
from
"./system-config.constant"
;
src/common/constants/permission.constant.ts
View file @
0b96ce87
...
@@ -68,6 +68,10 @@ export const PERMISSIONS = {
...
@@ -68,6 +68,10 @@ export const PERMISSIONS = {
// Dashboard
// Dashboard
DASHBOARD_READ
:
"dashboard.read"
,
DASHBOARD_READ
:
"dashboard.read"
,
DASHBOARD_READ_ALL
:
"dashboard.read_all"
,
DASHBOARD_READ_ALL
:
"dashboard.read_all"
,
// System Configs
SYSTEM_CONFIG_READ
:
"system_configs.read"
,
SYSTEM_CONFIG_MANAGE
:
"system_configs.manage"
,
}
as
const
;
}
as
const
;
export
type
PermissionSlug
=
(
typeof
PERMISSIONS
)[
keyof
typeof
PERMISSIONS
];
export
type
PermissionSlug
=
(
typeof
PERMISSIONS
)[
keyof
typeof
PERMISSIONS
];
...
@@ -471,6 +475,24 @@ export const SYSTEM_PERMISSIONS_CATALOG: PermissionDefinition[] = [
...
@@ -471,6 +475,24 @@ export const SYSTEM_PERMISSIONS_CATALOG: PermissionDefinition[] = [
action
:
"read_all"
,
action
:
"read_all"
,
isSystem
:
true
,
isSystem
:
true
,
},
},
// System Configs
{
name
:
"View System Configs"
,
slug
:
PERMISSIONS
.
SYSTEM_CONFIG_READ
,
description
:
"Xem danh sách và chi tiết cấu hình hệ thống & feature flags"
,
resource
:
"system_configs"
,
action
:
"read"
,
isSystem
:
true
,
},
{
name
:
"Manage System Configs"
,
slug
:
PERMISSIONS
.
SYSTEM_CONFIG_MANAGE
,
description
:
"Thêm, cập nhật, bật/tắt hoặc xóa cấu hình hệ thống & feature flags"
,
resource
:
"system_configs"
,
action
:
"manage"
,
isSystem
:
true
,
},
];
];
export
const
SYSTEM_ROLE_DEFAULT_PERMISSIONS
:
Record
<
export
const
SYSTEM_ROLE_DEFAULT_PERMISSIONS
:
Record
<
...
@@ -525,6 +547,8 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record<
...
@@ -525,6 +547,8 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record<
PERMISSIONS
.
AUDIT_LOGS_READ
,
PERMISSIONS
.
AUDIT_LOGS_READ
,
PERMISSIONS
.
DASHBOARD_READ
,
PERMISSIONS
.
DASHBOARD_READ
,
PERMISSIONS
.
DASHBOARD_READ_ALL
,
PERMISSIONS
.
DASHBOARD_READ_ALL
,
PERMISSIONS
.
SYSTEM_CONFIG_READ
,
PERMISSIONS
.
SYSTEM_CONFIG_MANAGE
,
],
],
[
SYSTEM_ROLE_SLUGS
.
CRAWLER_USER
]:
[
[
SYSTEM_ROLE_SLUGS
.
CRAWLER_USER
]:
[
PERMISSIONS
.
CRAWL_JOBS_CREATE
,
PERMISSIONS
.
CRAWL_JOBS_CREATE
,
...
...
src/common/constants/system-config.constant.ts
0 → 100644
View file @
0b96ce87
export
const
SYSTEM_CONFIG_CATEGORY
=
{
GENERAL
:
"GENERAL"
,
FEATURE_FLAG
:
"FEATURE_FLAG"
,
INTEGRATION
:
"INTEGRATION"
,
SECURITY
:
"SECURITY"
,
}
as
const
;
export
type
SystemConfigCategory
=
(
typeof
SYSTEM_CONFIG_CATEGORY
)[
keyof
typeof
SYSTEM_CONFIG_CATEGORY
];
export
const
SYSTEM_CONFIG_EVENTS_CHANNEL
=
"system_config:events"
;
export
const
SYSTEM_CONFIG_CACHE_TTL_MS
=
5
*
60
*
1000
;
// 5 minutes
export
interface
DefaultSystemConfigItem
{
key
:
string
;
value
:
unknown
;
category
:
SystemConfigCategory
;
isPublic
:
boolean
;
description
?:
string
;
}
export
const
DEFAULT_SYSTEM_CONFIGS
:
readonly
DefaultSystemConfigItem
[]
=
[
// ==========================================
// 1. GENERAL (Cấu hình chung, Ứng dụng & Lưu trữ)
// ==========================================
{
key
:
"app.name"
,
value
:
"Data Crawler Studio"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
true
,
description
:
"Tên ứng dụng hiển thị công khai trên hệ thống và thông báo"
,
},
{
key
:
"app.description"
,
value
:
"Nền tảng cào dữ liệu web, trích xuất cấu trúc và theo dõi biến động nội dung thông minh"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
true
,
description
:
"Mô tả giới thiệu hệ thống cho người dùng và các công cụ tìm kiếm"
,
},
{
key
:
"app.frontend_url"
,
value
:
"http://localhost:3000"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
true
,
description
:
"Địa chỉ Web Frontend điều hướng liên kết và callback xác thực (FRONTEND_URL)"
,
},
{
key
:
"app.support_email"
,
value
:
"support@datacrawler.com"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
true
,
description
:
"Hộp thư hỗ trợ kỹ thuật và chăm sóc người dùng hiển thị công khai"
,
},
{
key
:
"app.timezone"
,
value
:
"Asia/Ho_Chi_Minh"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
true
,
description
:
"Múi giờ mặc định xử lý tác vụ và lịch biểu thu thập dữ liệu"
,
},
{
key
:
"crawler.max_pages_default"
,
value
:
100
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Số lượng trang cào tối đa mặc định cho một tác vụ cào dữ liệu (MAX_CRAWL_PAGES)"
,
},
{
key
:
"crawler.max_depth_default"
,
value
:
3
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Độ sâu liên kết tối đa mặc định khi thu thập dữ liệu web (MAX_CRAWL_DEPTH)"
,
},
{
key
:
"crawler.timeout_ms"
,
value
:
60000
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Thời gian chờ tối đa cho mỗi yêu cầu HTTP thu thập trang (FIRECRAWL_REQUEST_TIMEOUT_MS)"
,
},
{
key
:
"crawler.worker_concurrency"
,
value
:
3
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Số lượng luồng worker chạy đồng thời xử lý hàng đợi cào dữ liệu (WORKER_CONCURRENCY)"
,
},
{
key
:
"crawler.job_timeout_ms"
,
value
:
300000
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Thời gian chạy tối đa cho toàn bộ một tiến trình cào trước khi timeout (WORKER_JOB_TIMEOUT_MS)"
,
},
{
key
:
"crawler.max_stalled_count"
,
value
:
1
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Số lần tối đa cho phép worker khôi phục tác vụ cào khi bị treo (WORKER_MAX_STALLED_COUNT)"
,
},
{
key
:
"storage.default_driver"
,
value
:
"s3"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Driver lưu trữ tệp xuất dữ liệu mặc định: 's3' hoặc 'local' (STORAGE_DRIVER)"
,
},
{
key
:
"storage.export_dir"
,
value
:
"storage/exports"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Thư mục lưu trữ tạm thời các gói nén ZIP trên máy chủ (STORAGE_EXPORT_DIR)"
,
},
{
key
:
"storage.s3.bucket"
,
value
:
"data-crawler-exports"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Tên bucket lưu trữ các tệp xuất dữ liệu cào dạng nén ZIP (S3_BUCKET)"
,
},
{
key
:
"storage.s3.region"
,
value
:
"us-east-1"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Vùng máy chủ lưu trữ dữ liệu tệp nén S3 (S3_REGION)"
,
},
{
key
:
"storage.s3.endpoint"
,
value
:
"http://127.0.0.1:9000"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
description
:
"Địa chỉ Endpoint kết nối dịch vụ lưu trữ AWS S3 hoặc MinIO (S3_ENDPOINT)"
,
},
// ==========================================
// 2. FEATURE_FLAG (Cờ bật/tắt tính năng động)
// ==========================================
{
key
:
"feature.registration.enabled"
,
value
:
true
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
description
:
"Cờ tính năng: Cho phép người dùng mới đăng ký tài khoản tự do trên hệ thống"
,
},
{
key
:
"feature.ai.enabled"
,
value
:
false
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
description
:
"Cờ tính năng: Kích hoạt các công cụ phân tích, trích xuất cấu trúc và làm sạch bằng AI"
,
},
{
key
:
"feature.maintenance_mode.enabled"
,
value
:
false
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
description
:
"Cờ tính năng: Kích hoạt chế độ bảo trì hệ thống toàn diện, tạm ngừng nhận tác vụ mới"
,
},
{
key
:
"feature.change_detection.enabled"
,
value
:
true
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
description
:
"Cờ tính năng: Tự động so sánh và tạo báo cáo Diff biến động nội dung khi cào định kỳ"
,
},
{
key
:
"feature.export.zip.enabled"
,
value
:
true
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
description
:
"Cờ tính năng: Cho phép đóng gói và xuất toàn bộ dữ liệu cào sang tệp nén ZIP"
,
},
{
key
:
"feature.webhook.deliveries.enabled"
,
value
:
true
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
description
:
"Cờ tính năng: Kích hoạt cơ chế phát webhook tự động đến các endpoint đã đăng ký"
,
},
{
key
:
"feature.dark_mode_default.enabled"
,
value
:
true
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
description
:
"Cờ tính năng: Gợi ý bật giao diện tối (Dark Mode) mặc định cho người dùng mới"
,
},
{
key
:
"feature.social_login.enabled"
,
value
:
false
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
description
:
"Cờ tính năng: Cho phép đăng nhập nhanh qua Google hoặc GitHub OAuth"
,
},
// ==========================================
// 3. INTEGRATION (Tích hợp dịch vụ, Firecrawl & Webhook)
// ==========================================
{
key
:
"integration.discord.invite_url"
,
value
:
"https://discord.gg/datacrawler"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
INTEGRATION
,
isPublic
:
true
,
description
:
"Đường dẫn lời mời tham gia cộng đồng hỗ trợ trên Discord"
,
},
{
key
:
"integration.telegram.bot_username"
,
value
:
"DataCrawlerBot"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
INTEGRATION
,
isPublic
:
true
,
description
:
"Tên người dùng Bot Telegram chính thức để nhận thông báo và điều khiển"
,
},
{
key
:
"integration.firecrawl.api_key"
,
value
:
"fc-ae80a8acc69a4f42a8bca0e80370077e"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
INTEGRATION
,
isPublic
:
false
,
description
:
"Khóa Firecrawl API Key điều khiển engine cào DOM (xoay tua & đổi động mà không cần restart)"
,
},
{
key
:
"integration.firecrawl.base_url"
,
value
:
"https://api.firecrawl.dev"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
INTEGRATION
,
isPublic
:
false
,
description
:
"Địa chỉ API gốc của dịch vụ Firecrawl thu thập DOM (FIRECRAWL_BASE_URL)"
,
},
{
key
:
"integration.smtp.from_name"
,
value
:
"Data Crawler Support"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
INTEGRATION
,
isPublic
:
true
,
description
:
"Tên người gửi hiển thị trong các email hệ thống (SMTP_FROM)"
,
},
{
key
:
"integration.smtp.from_email"
,
value
:
"no-reply@datacrawler.com"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
INTEGRATION
,
isPublic
:
true
,
description
:
"Địa chỉ email gửi thông báo hệ thống và mã kích hoạt (SMTP_FROM)"
,
},
{
key
:
"integration.webhook.retry_limit"
,
value
:
3
,
category
:
SYSTEM_CONFIG_CATEGORY
.
INTEGRATION
,
isPublic
:
false
,
description
:
"Số lần tự động thử lại tối đa khi gửi webhook thông báo thất bại"
,
},
{
key
:
"integration.webhook.timeout_ms"
,
value
:
10000
,
category
:
SYSTEM_CONFIG_CATEGORY
.
INTEGRATION
,
isPublic
:
false
,
description
:
"Thời gian chờ tối đa cho mỗi yêu cầu gửi webhook (ms)"
,
},
// ==========================================
// 4. SECURITY (Bảo mật, Token, Quotas & Rate Limits)
// ==========================================
{
key
:
"jwt.access_expires_in"
,
value
:
"1d"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Thời gian hết hạn của JWT Access Token cho phiên đăng nhập (JWT_ACCESS_EXPIRES_IN)"
,
},
{
key
:
"jwt.refresh_expires_in"
,
value
:
"7d"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Thời gian hết hạn của Refresh Token cho phiên đăng nhập (JWT_REFRESH_EXPIRES_IN)"
,
},
{
key
:
"quota.user_max_pages"
,
value
:
100
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Hạn ngạch số trang cào tối đa mặc định cho tài khoản người dùng thông thường (USER_MAX_PAGES)"
,
},
{
key
:
"quota.user_max_jobs_per_day"
,
value
:
10
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Hạn ngạch số tác vụ cào tối đa trong một ngày cho mỗi tài khoản (USER_MAX_JOBS_PER_DAY)"
,
},
{
key
:
"quota.user_max_concurrent_jobs"
,
value
:
3
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Hạn ngạch số tác vụ cào được phép chạy song song cho mỗi tài khoản (USER_MAX_CONCURRENT_JOBS)"
,
},
{
key
:
"rate_limit.window_ms"
,
value
:
900000
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Chu kỳ tính giới hạn yêu cầu API (ms, tương đương 15 phút) (RATE_LIMIT_WINDOW_MS)"
,
},
{
key
:
"rate_limit.max_requests"
,
value
:
1000
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Số lượng yêu cầu API tối đa được phép trong một chu kỳ giới hạn (RATE_LIMIT_MAX)"
,
},
{
key
:
"rate_limit.max_requests_per_minute"
,
value
:
60
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Số lượng request tối đa cho phép mỗi phút cho mỗi địa chỉ IP"
,
},
{
key
:
"security.password_min_length"
,
value
:
8
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
true
,
description
:
"Độ dài tối thiểu của mật khẩu khi đăng ký hoặc đổi mật khẩu"
,
},
{
key
:
"security.session_timeout_minutes"
,
value
:
1440
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Thời hạn hết hạn của phiên làm việc Access Token (phút, tương đương 24 giờ)"
,
},
{
key
:
"security.max_login_attempts"
,
value
:
5
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
description
:
"Số lần đăng nhập sai tối đa trước khi tài khoản bị tạm khóa"
,
},
]
as
const
;
src/common/redis/redis-pubsub.ts
0 → 100644
View file @
0b96ce87
import
Redis
from
"ioredis"
;
import
{
envConfig
}
from
"../../config/env.config"
;
let
publisherClient
:
Redis
|
null
=
null
;
let
subscriberClient
:
Redis
|
null
=
null
;
export
function
getRedisPublisher
():
Redis
|
null
{
if
(
!
envConfig
.
redis
.
enabled
)
return
null
;
if
(
!
publisherClient
)
{
try
{
publisherClient
=
new
Redis
({
host
:
envConfig
.
redis
.
host
,
port
:
envConfig
.
redis
.
port
,
maxRetriesPerRequest
:
1
,
lazyConnect
:
true
,
connectTimeout
:
2000
,
retryStrategy
:
()
=>
null
,
enableOfflineQueue
:
false
,
});
publisherClient
.
on
(
"error"
,
()
=>
{
// Suppress unhandled redis error crashes
});
}
catch
{
publisherClient
=
null
;
}
}
return
publisherClient
;
}
export
function
getRedisSubscriber
():
Redis
|
null
{
if
(
!
envConfig
.
redis
.
enabled
)
return
null
;
if
(
!
subscriberClient
)
{
try
{
subscriberClient
=
new
Redis
({
host
:
envConfig
.
redis
.
host
,
port
:
envConfig
.
redis
.
port
,
maxRetriesPerRequest
:
1
,
lazyConnect
:
true
,
connectTimeout
:
2000
,
retryStrategy
:
()
=>
null
,
enableOfflineQueue
:
false
,
});
subscriberClient
.
on
(
"error"
,
()
=>
{
// Suppress unhandled redis error crashes
});
}
catch
{
subscriberClient
=
null
;
}
}
return
subscriberClient
;
}
src/docs/swagger-paths.ts
View file @
0b96ce87
...
@@ -3443,4 +3443,251 @@ export const swaggerPaths: Record<string, any> = {
...
@@ -3443,4 +3443,251 @@ export const swaggerPaths: Record<string, any> = {
},
},
},
},
},
},
"/system/public"
:
{
get
:
{
tags
:
[
"System Config"
],
summary
:
"Lấy cấu hình công khai và Feature Flags"
,
description
:
"Cho phép client/frontend đọc toàn bộ cấu hình có isPublic: true mà không cần đăng nhập."
,
responses
:
{
200
:
{
description
:
"Lấy cấu hình công khai thành công"
,
content
:
{
"application/json"
:
{
schema
:
{
type
:
"object"
,
properties
:
{
success
:
{
type
:
"boolean"
,
example
:
true
},
data
:
{
type
:
"object"
,
properties
:
{
configs
:
{
type
:
"array"
,
items
:
{
$ref
:
"#/components/schemas/SystemConfig"
},
},
map
:
{
type
:
"object"
},
},
},
},
},
},
},
},
},
},
},
"/system/configs"
:
{
get
:
{
tags
:
[
"System Config"
],
summary
:
"Danh sách cấu hình hệ thống"
,
description
:
"Lấy danh sách cấu hình và cờ tính năng, hỗ trợ tìm kiếm và lọc theo danh mục (Yêu cầu quyền SYSTEM_CONFIG_READ)."
,
parameters
:
[
{
name
:
"category"
,
in
:
"query"
,
schema
:
{
type
:
"string"
,
enum
:
[
"GENERAL"
,
"FEATURE_FLAG"
,
"INTEGRATION"
,
"SECURITY"
],
},
description
:
"Lọc theo danh mục cấu hình"
,
},
{
name
:
"search"
,
in
:
"query"
,
schema
:
{
type
:
"string"
},
description
:
"Tìm kiếm theo khóa hoặc mô tả"
,
},
{
name
:
"page"
,
in
:
"query"
,
schema
:
{
type
:
"integer"
,
default
:
1
},
},
{
name
:
"limit"
,
in
:
"query"
,
schema
:
{
type
:
"integer"
,
default
:
20
},
},
],
responses
:
{
200
:
{
description
:
"Lấy danh sách thành công"
,
content
:
{
"application/json"
:
{
schema
:
{
type
:
"object"
,
properties
:
{
success
:
{
type
:
"boolean"
,
example
:
true
},
data
:
{
type
:
"object"
,
properties
:
{
items
:
{
type
:
"array"
,
items
:
{
$ref
:
"#/components/schemas/SystemConfig"
},
},
total
:
{
type
:
"integer"
},
page
:
{
type
:
"integer"
},
limit
:
{
type
:
"integer"
},
},
},
},
},
},
},
},
401
:
{
description
:
"Chưa xác thực"
},
403
:
{
description
:
"Không có quyền SYSTEM_CONFIG_READ"
},
},
},
post
:
{
tags
:
[
"System Config"
],
summary
:
"Tạo cấu hình mới"
,
description
:
"Tạo mới một khóa cấu hình hoặc Feature Flag (Yêu cầu quyền SYSTEM_CONFIG_MANAGE)."
,
requestBody
:
{
required
:
true
,
content
:
{
"application/json"
:
{
schema
:
{
$ref
:
"#/components/schemas/CreateSystemConfigRequest"
},
},
},
},
responses
:
{
201
:
{
description
:
"Tạo cấu hình thành công"
,
content
:
{
"application/json"
:
{
schema
:
{
type
:
"object"
,
properties
:
{
success
:
{
type
:
"boolean"
,
example
:
true
},
data
:
{
$ref
:
"#/components/schemas/SystemConfig"
},
message
:
{
type
:
"string"
},
},
},
},
},
},
400
:
{
description
:
"Dữ liệu không hợp lệ"
},
409
:
{
description
:
"Khóa cấu hình đã tồn tại"
},
},
},
},
"/system/configs/{key}"
:
{
get
:
{
tags
:
[
"System Config"
],
summary
:
"Chi tiết một cấu hình"
,
parameters
:
[
{
name
:
"key"
,
in
:
"path"
,
required
:
true
,
schema
:
{
type
:
"string"
},
description
:
"Khóa định danh cấu hình"
,
},
],
responses
:
{
200
:
{
description
:
"Lấy chi tiết thành công"
,
content
:
{
"application/json"
:
{
schema
:
{
type
:
"object"
,
properties
:
{
success
:
{
type
:
"boolean"
,
example
:
true
},
data
:
{
$ref
:
"#/components/schemas/SystemConfig"
},
},
},
},
},
},
404
:
{
description
:
"Không tìm thấy cấu hình"
},
},
},
put
:
{
tags
:
[
"System Config"
],
summary
:
"Cập nhật cấu hình"
,
parameters
:
[
{
name
:
"key"
,
in
:
"path"
,
required
:
true
,
schema
:
{
type
:
"string"
},
},
],
requestBody
:
{
required
:
true
,
content
:
{
"application/json"
:
{
schema
:
{
$ref
:
"#/components/schemas/UpdateSystemConfigRequest"
},
},
},
},
responses
:
{
200
:
{
description
:
"Cập nhật thành công"
,
content
:
{
"application/json"
:
{
schema
:
{
type
:
"object"
,
properties
:
{
success
:
{
type
:
"boolean"
,
example
:
true
},
data
:
{
$ref
:
"#/components/schemas/SystemConfig"
},
},
},
},
},
},
404
:
{
description
:
"Không tìm thấy cấu hình"
},
},
},
delete
:
{
tags
:
[
"System Config"
],
summary
:
"Xóa cấu hình"
,
parameters
:
[
{
name
:
"key"
,
in
:
"path"
,
required
:
true
,
schema
:
{
type
:
"string"
},
},
],
responses
:
{
200
:
{
description
:
"Xóa thành công"
},
404
:
{
description
:
"Không tìm thấy cấu hình"
},
},
},
},
"/system/features/{key}/toggle"
:
{
patch
:
{
tags
:
[
"System Config"
],
summary
:
"Bật/tắt nhanh Feature Flag"
,
description
:
"Chuyển đổi trạng thái boolean (true <-> false) cho một cờ tính năng (Yêu cầu quyền SYSTEM_CONFIG_MANAGE)."
,
parameters
:
[
{
name
:
"key"
,
in
:
"path"
,
required
:
true
,
schema
:
{
type
:
"string"
},
},
],
responses
:
{
200
:
{
description
:
"Chuyển đổi trạng thái thành công"
,
content
:
{
"application/json"
:
{
schema
:
{
type
:
"object"
,
properties
:
{
success
:
{
type
:
"boolean"
,
example
:
true
},
data
:
{
$ref
:
"#/components/schemas/SystemConfig"
},
message
:
{
type
:
"string"
},
},
},
},
},
},
400
:
{
description
:
"Cấu hình không phải boolean Feature Flag"
},
404
:
{
description
:
"Không tìm thấy cấu hình"
},
},
},
},
};
};
src/docs/swagger.json
View file @
0b96ce87
...
@@ -1833,6 +1833,356 @@
...
@@ -1833,6 +1833,356 @@
"summary"
:
"Chi tiết Permission"
"summary"
:
"Chi tiết Permission"
}
}
},
},
"/system/public"
:
{
"get"
:
{
"description"
:
"Cho phép client/frontend đọc toàn bộ cấu hình có isPublic: true mà không cần đăng nhập."
,
"responses"
:
{
"200"
:
{
"description"
:
"Lấy cấu hình công khai thành công"
,
"content"
:
{
"application/json"
:
{
"schema"
:
{
"type"
:
"object"
,
"properties"
:
{
"success"
:
{
"type"
:
"boolean"
,
"example"
:
true
},
"data"
:
{
"type"
:
"object"
,
"properties"
:
{
"configs"
:
{
"type"
:
"array"
,
"items"
:
{
"$ref"
:
"#/components/schemas/SystemConfig"
}
},
"map"
:
{
"type"
:
"object"
}
}
}
}
}
}
}
}
},
"tags"
:
[
"System Config"
],
"summary"
:
"Lấy cấu hình công khai và Feature Flags"
}
},
"/system/configs"
:
{
"get"
:
{
"description"
:
"Lấy danh sách cấu hình và cờ tính năng, hỗ trợ tìm kiếm và lọc theo danh mục (Yêu cầu quyền SYSTEM_CONFIG_READ)."
,
"parameters"
:
[
{
"name"
:
"category"
,
"in"
:
"query"
,
"schema"
:
{
"type"
:
"string"
,
"enum"
:
[
"GENERAL"
,
"FEATURE_FLAG"
,
"INTEGRATION"
,
"SECURITY"
]
},
"description"
:
"Lọc theo danh mục cấu hình"
},
{
"name"
:
"search"
,
"in"
:
"query"
,
"schema"
:
{
"type"
:
"string"
},
"description"
:
"Tìm kiếm theo khóa hoặc mô tả"
},
{
"name"
:
"page"
,
"in"
:
"query"
,
"schema"
:
{
"type"
:
"integer"
,
"default"
:
1
}
},
{
"name"
:
"limit"
,
"in"
:
"query"
,
"schema"
:
{
"type"
:
"integer"
,
"default"
:
20
}
}
],
"responses"
:
{
"200"
:
{
"description"
:
"Lấy danh sách thành công"
,
"content"
:
{
"application/json"
:
{
"schema"
:
{
"type"
:
"object"
,
"properties"
:
{
"success"
:
{
"type"
:
"boolean"
,
"example"
:
true
},
"data"
:
{
"type"
:
"object"
,
"properties"
:
{
"items"
:
{
"type"
:
"array"
,
"items"
:
{
"$ref"
:
"#/components/schemas/SystemConfig"
}
},
"total"
:
{
"type"
:
"integer"
},
"page"
:
{
"type"
:
"integer"
},
"limit"
:
{
"type"
:
"integer"
}
}
}
}
}
}
}
},
"401"
:
{
"description"
:
"Chưa xác thực"
},
"403"
:
{
"description"
:
"Không có quyền SYSTEM_CONFIG_READ"
}
},
"tags"
:
[
"System Config"
],
"summary"
:
"Danh sách cấu hình hệ thống"
},
"post"
:
{
"description"
:
"Tạo mới một khóa cấu hình hoặc Feature Flag (Yêu cầu quyền SYSTEM_CONFIG_MANAGE)."
,
"responses"
:
{
"201"
:
{
"description"
:
"Tạo cấu hình thành công"
,
"content"
:
{
"application/json"
:
{
"schema"
:
{
"type"
:
"object"
,
"properties"
:
{
"success"
:
{
"type"
:
"boolean"
,
"example"
:
true
},
"data"
:
{
"$ref"
:
"#/components/schemas/SystemConfig"
},
"message"
:
{
"type"
:
"string"
}
}
}
}
}
},
"400"
:
{
"description"
:
"Dữ liệu không hợp lệ"
},
"409"
:
{
"description"
:
"Khóa cấu hình đã tồn tại"
}
},
"tags"
:
[
"System Config"
],
"summary"
:
"Tạo cấu hình mới"
,
"requestBody"
:
{
"required"
:
true
,
"content"
:
{
"application/json"
:
{
"schema"
:
{
"$ref"
:
"#/components/schemas/CreateSystemConfigRequest"
}
}
}
}
}
},
"/system/configs/{key}"
:
{
"get"
:
{
"description"
:
""
,
"parameters"
:
[
{
"name"
:
"key"
,
"in"
:
"path"
,
"required"
:
true
,
"schema"
:
{
"type"
:
"string"
},
"description"
:
"Khóa định danh cấu hình"
}
],
"responses"
:
{
"200"
:
{
"description"
:
"Lấy chi tiết thành công"
,
"content"
:
{
"application/json"
:
{
"schema"
:
{
"type"
:
"object"
,
"properties"
:
{
"success"
:
{
"type"
:
"boolean"
,
"example"
:
true
},
"data"
:
{
"$ref"
:
"#/components/schemas/SystemConfig"
}
}
}
}
}
},
"404"
:
{
"description"
:
"Không tìm thấy cấu hình"
}
},
"tags"
:
[
"System Config"
],
"summary"
:
"Chi tiết một cấu hình"
},
"put"
:
{
"description"
:
""
,
"parameters"
:
[
{
"name"
:
"key"
,
"in"
:
"path"
,
"required"
:
true
,
"schema"
:
{
"type"
:
"string"
}
}
],
"responses"
:
{
"200"
:
{
"description"
:
"Cập nhật thành công"
,
"content"
:
{
"application/json"
:
{
"schema"
:
{
"type"
:
"object"
,
"properties"
:
{
"success"
:
{
"type"
:
"boolean"
,
"example"
:
true
},
"data"
:
{
"$ref"
:
"#/components/schemas/SystemConfig"
}
}
}
}
}
},
"404"
:
{
"description"
:
"Không tìm thấy cấu hình"
}
},
"tags"
:
[
"System Config"
],
"summary"
:
"Cập nhật cấu hình"
,
"requestBody"
:
{
"required"
:
true
,
"content"
:
{
"application/json"
:
{
"schema"
:
{
"$ref"
:
"#/components/schemas/UpdateSystemConfigRequest"
}
}
}
}
},
"delete"
:
{
"description"
:
""
,
"parameters"
:
[
{
"name"
:
"key"
,
"in"
:
"path"
,
"required"
:
true
,
"schema"
:
{
"type"
:
"string"
}
}
],
"responses"
:
{
"200"
:
{
"description"
:
"Xóa thành công"
},
"404"
:
{
"description"
:
"Không tìm thấy cấu hình"
}
},
"tags"
:
[
"System Config"
],
"summary"
:
"Xóa cấu hình"
}
},
"/system/features/{key}/toggle"
:
{
"patch"
:
{
"description"
:
"Chuyển đổi trạng thái boolean (true <-> false) cho một cờ tính năng (Yêu cầu quyền SYSTEM_CONFIG_MANAGE)."
,
"parameters"
:
[
{
"name"
:
"key"
,
"in"
:
"path"
,
"required"
:
true
,
"schema"
:
{
"type"
:
"string"
}
}
],
"responses"
:
{
"200"
:
{
"description"
:
"Chuyển đổi trạng thái thành công"
,
"content"
:
{
"application/json"
:
{
"schema"
:
{
"type"
:
"object"
,
"properties"
:
{
"success"
:
{
"type"
:
"boolean"
,
"example"
:
true
},
"data"
:
{
"$ref"
:
"#/components/schemas/SystemConfig"
},
"message"
:
{
"type"
:
"string"
}
}
}
}
}
},
"400"
:
{
"description"
:
"Cấu hình không phải boolean Feature Flag"
},
"404"
:
{
"description"
:
"Không tìm thấy cấu hình"
}
},
"tags"
:
[
"System Config"
],
"summary"
:
"Bật/tắt nhanh Feature Flag"
}
},
"/dashboard/stats"
:
{
"/dashboard/stats"
:
{
"get"
:
{
"get"
:
{
"description"
:
"Thống kê tổng hợp số lượng crawl jobs theo trạng thái, số trang đã crawl, số lịch crawl đang chạy và tổng số tệp export."
,
"description"
:
"Thống kê tổng hợp số lượng crawl jobs theo trạng thái, số trang đã crawl, số lịch crawl đang chạy và tổng số tệp export."
,
...
@@ -6450,6 +6800,107 @@
...
@@ -6450,6 +6800,107 @@
}
}
}
}
}
}
},
"SystemConfig"
:
{
"type"
:
"object"
,
"properties"
:
{
"id"
:
{
"type"
:
"string"
,
"format"
:
"uuid"
},
"key"
:
{
"type"
:
"string"
,
"example"
:
"feature.ai.enabled"
},
"value"
:
{
"example"
:
true
},
"description"
:
{
"type"
:
"string"
,
"nullable"
:
true
,
"example"
:
"Kích hoạt AI"
},
"category"
:
{
"type"
:
"string"
,
"enum"
:
[
"GENERAL"
,
"FEATURE_FLAG"
,
"INTEGRATION"
,
"SECURITY"
],
"example"
:
"FEATURE_FLAG"
},
"isPublic"
:
{
"type"
:
"boolean"
,
"example"
:
true
},
"createdAt"
:
{
"type"
:
"string"
,
"format"
:
"date-time"
},
"updatedAt"
:
{
"type"
:
"string"
,
"format"
:
"date-time"
}
}
},
"CreateSystemConfigRequest"
:
{
"type"
:
"object"
,
"required"
:
[
"key"
,
"value"
],
"properties"
:
{
"key"
:
{
"type"
:
"string"
,
"example"
:
"feature.new_module.enabled"
},
"value"
:
{
"example"
:
true
},
"description"
:
{
"type"
:
"string"
,
"example"
:
"Bật tắt tính năng mới"
},
"category"
:
{
"type"
:
"string"
,
"enum"
:
[
"GENERAL"
,
"FEATURE_FLAG"
,
"INTEGRATION"
,
"SECURITY"
],
"example"
:
"FEATURE_FLAG"
},
"isPublic"
:
{
"type"
:
"boolean"
,
"example"
:
false
}
}
},
"UpdateSystemConfigRequest"
:
{
"type"
:
"object"
,
"properties"
:
{
"value"
:
{
"example"
:
false
},
"description"
:
{
"type"
:
"string"
,
"example"
:
"Mô tả mới"
},
"category"
:
{
"type"
:
"string"
,
"enum"
:
[
"GENERAL"
,
"FEATURE_FLAG"
,
"INTEGRATION"
,
"SECURITY"
]
},
"isPublic"
:
{
"type"
:
"boolean"
}
}
}
}
}
}
},
},
...
...
src/docs/swagger.ts
View file @
0b96ce87
...
@@ -811,6 +811,50 @@ const rawSchemas = {
...
@@ -811,6 +811,50 @@ const rawSchemas = {
},
},
},
},
},
},
SystemConfig
:
{
type
:
"object"
,
properties
:
{
id
:
{
type
:
"string"
,
format
:
"uuid"
},
key
:
{
type
:
"string"
,
example
:
"feature.ai.enabled"
},
value
:
{
example
:
true
},
description
:
{
type
:
"string"
,
nullable
:
true
,
example
:
"Kích hoạt AI"
},
category
:
{
type
:
"string"
,
enum
:
[
"GENERAL"
,
"FEATURE_FLAG"
,
"INTEGRATION"
,
"SECURITY"
],
example
:
"FEATURE_FLAG"
,
},
isPublic
:
{
type
:
"boolean"
,
example
:
true
},
createdAt
:
{
type
:
"string"
,
format
:
"date-time"
},
updatedAt
:
{
type
:
"string"
,
format
:
"date-time"
},
},
},
CreateSystemConfigRequest
:
{
type
:
"object"
,
required
:
[
"key"
,
"value"
],
properties
:
{
key
:
{
type
:
"string"
,
example
:
"feature.new_module.enabled"
},
value
:
{
example
:
true
},
description
:
{
type
:
"string"
,
example
:
"Bật tắt tính năng mới"
},
category
:
{
type
:
"string"
,
enum
:
[
"GENERAL"
,
"FEATURE_FLAG"
,
"INTEGRATION"
,
"SECURITY"
],
example
:
"FEATURE_FLAG"
,
},
isPublic
:
{
type
:
"boolean"
,
example
:
false
},
},
},
UpdateSystemConfigRequest
:
{
type
:
"object"
,
properties
:
{
value
:
{
example
:
false
},
description
:
{
type
:
"string"
,
example
:
"Mô tả mới"
},
category
:
{
type
:
"string"
,
enum
:
[
"GENERAL"
,
"FEATURE_FLAG"
,
"INTEGRATION"
,
"SECURITY"
],
},
isPublic
:
{
type
:
"boolean"
},
},
},
};
};
const
outputFile
=
"./src/docs/swagger.json"
;
const
outputFile
=
"./src/docs/swagger.json"
;
...
...
src/middlewares/maintenance.middleware.ts
0 → 100644
View file @
0b96ce87
import
{
Request
,
Response
,
NextFunction
}
from
"express"
;
import
{
systemConfigService
}
from
"../modules/system-config/system-config.service"
;
import
{
ERROR_CODE
}
from
"../common/errors/error-code"
;
/**
* Middleware kiểm tra chế độ bảo trì toàn hệ thống (feature.maintenance_mode.enabled)
* Khi bảo trì được bật, chặn các request từ người dùng thông thường,
* ngoại trừ các endpoint quản trị cấu hình, đăng nhập và health check.
*/
export
async
function
maintenanceMiddleware
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
)
{
// Bỏ qua các endpoint thiết yếu để Admin vẫn có thể đăng nhập và tắt chế độ bảo trì
const
publicPaths
=
[
"/system"
,
"/auth/login"
,
"/auth/refresh"
,
"/api-docs"
,
"/health"
,
];
const
isPublicOrAdminExempt
=
publicPaths
.
some
(
(
prefix
)
=>
req
.
path
===
prefix
||
req
.
path
.
startsWith
(
prefix
+
"/"
),
);
if
(
isPublicOrAdminExempt
)
{
return
next
();
}
const
isMaintenanceMode
=
await
systemConfigService
.
isFeatureEnabled
(
"feature.maintenance_mode.enabled"
,
false
,
);
if
(
isMaintenanceMode
)
{
// Nếu là Admin thì cho phép qua
const
user
=
(
req
as
any
).
user
;
if
(
user
?.
role
===
"ADMIN"
)
{
return
next
();
}
return
res
.
status
(
503
).
json
({
success
:
false
,
message
:
"Hệ thống đang trong chế độ bảo trì định kỳ để nâng cấp. Vui lòng quay lại sau ít phút."
,
code
:
ERROR_CODE
.
INTERNAL_SERVER_ERROR
,
});
}
next
();
}
src/middlewares/rate-limit.middleware.ts
View file @
0b96ce87
import
rateLimit
,
{
RateLimitRequestHandler
}
from
"express-rate-limit"
;
import
rateLimit
,
{
RateLimitRequestHandler
}
from
"express-rate-limit"
;
import
{
envConfig
}
from
"../config/env.config"
;
import
{
envConfig
}
from
"../config/env.config"
;
import
{
ERROR_CODE
}
from
"../common/errors/error-code"
;
import
{
ERROR_CODE
}
from
"../common/errors/error-code"
;
import
{
systemConfigService
}
from
"../modules/system-config/system-config.service"
;
/**
/**
* Global API rate limit per IP, configurable for each environment.
* Global API rate limit per IP, configurable for each environment.
...
@@ -9,7 +10,11 @@ import { ERROR_CODE } from "../common/errors/error-code";
...
@@ -9,7 +10,11 @@ import { ERROR_CODE } from "../common/errors/error-code";
*/
*/
export
const
rateLimitMiddleware
:
RateLimitRequestHandler
=
rateLimit
({
export
const
rateLimitMiddleware
:
RateLimitRequestHandler
=
rateLimit
({
windowMs
:
envConfig
.
rateLimit
.
windowMs
,
windowMs
:
envConfig
.
rateLimit
.
windowMs
,
max
:
envConfig
.
rateLimit
.
max
,
max
:
async
()
=>
systemConfigService
.
get
<
number
>
(
"rate_limit.max_requests"
,
envConfig
.
rateLimit
.
max
,
),
standardHeaders
:
true
,
standardHeaders
:
true
,
legacyHeaders
:
false
,
legacyHeaders
:
false
,
message
:
{
message
:
{
...
...
src/modules/auth/auth.service.ts
View file @
0b96ce87
...
@@ -32,6 +32,7 @@ import {
...
@@ -32,6 +32,7 @@ import {
createUtcDateFromZonedParts
,
createUtcDateFromZonedParts
,
}
from
"../../common/helpers/schedule-calculator.helper"
;
}
from
"../../common/helpers/schedule-calculator.helper"
;
import
{
PermissionService
}
from
"../permissions/permission.service"
;
import
{
PermissionService
}
from
"../permissions/permission.service"
;
import
{
systemConfigService
}
from
"../system-config/system-config.service"
;
interface
AuthJwtPayload
{
interface
AuthJwtPayload
{
id
:
string
;
id
:
string
;
...
@@ -279,6 +280,18 @@ export class AuthService {
...
@@ -279,6 +280,18 @@ export class AuthService {
}
}
async
register
(
data
:
RegisterDto
):
Promise
<
MeDto
>
{
async
register
(
data
:
RegisterDto
):
Promise
<
MeDto
>
{
const
isRegistrationEnabled
=
await
systemConfigService
.
isFeatureEnabled
(
"feature.registration.enabled"
,
true
,
);
if
(
!
isRegistrationEnabled
)
{
throw
new
AppError
(
"Tính năng đăng ký tài khoản hiện đang tạm khóa bởi Quản trị viên."
,
403
,
ERROR_CODE
.
FORBIDDEN
,
);
}
const
existing
=
await
this
.
repository
.
findByEmail
(
data
.
email
);
const
existing
=
await
this
.
repository
.
findByEmail
(
data
.
email
);
if
(
existing
)
{
if
(
existing
)
{
...
...
src/modules/firecrawl/firecrawl.client.ts
View file @
0b96ce87
import
FirecrawlApp
from
"@mendable/firecrawl-js"
;
import
FirecrawlApp
from
"@mendable/firecrawl-js"
;
import
{
firecrawlConfig
}
from
"../../config/firecrawl.config"
;
import
{
firecrawlConfig
}
from
"../../config/firecrawl.config"
;
import
{
systemConfigService
}
from
"../system-config/system-config.service"
;
let
cachedApiKey
:
string
|
null
=
null
;
let
cachedBaseUrl
:
string
|
null
=
null
;
let
firecrawlClient
:
FirecrawlApp
|
null
=
null
;
let
firecrawlClient
:
FirecrawlApp
|
null
=
null
;
export
async
function
getDynamicFirecrawlClient
():
Promise
<
FirecrawlApp
>
{
const
apiKey
=
await
systemConfigService
.
get
<
string
>
(
"integration.firecrawl.api_key"
,
firecrawlConfig
.
apiKey
,
);
const
baseUrl
=
await
systemConfigService
.
get
<
string
>
(
"integration.firecrawl.base_url"
,
firecrawlConfig
.
baseUrl
,
);
const
effectiveKey
=
apiKey
||
firecrawlConfig
.
apiKey
;
const
effectiveUrl
=
baseUrl
||
firecrawlConfig
.
baseUrl
;
if
(
!
effectiveKey
)
{
throw
new
Error
(
"FIRECRAWL_API_KEY is not set. Add it in System Config or .env file before using the crawler."
,
);
}
if
(
!
firecrawlClient
||
cachedApiKey
!==
effectiveKey
||
cachedBaseUrl
!==
effectiveUrl
)
{
cachedApiKey
=
effectiveKey
;
cachedBaseUrl
=
effectiveUrl
;
firecrawlClient
=
new
FirecrawlApp
({
apiKey
:
effectiveKey
,
apiUrl
:
effectiveUrl
,
});
}
return
firecrawlClient
;
}
export
function
getFirecrawlClient
():
FirecrawlApp
{
export
function
getFirecrawlClient
():
FirecrawlApp
{
if
(
!
firecrawlClient
)
{
if
(
!
firecrawlClient
)
{
if
(
!
firecrawlConfig
.
apiKey
)
{
if
(
!
firecrawlConfig
.
apiKey
)
{
...
...
src/modules/system-config/__tests__/system-config.service.test.ts
0 → 100644
View file @
0b96ce87
import
{
SystemConfigService
}
from
"../system-config.service"
;
import
{
SystemConfigRepository
}
from
"../system-config.repository"
;
import
{
AuditLogService
}
from
"../../audit-logs/audit-log.service"
;
import
{
SYSTEM_CONFIG_CATEGORY
,
DEFAULT_SYSTEM_CONFIGS
,
}
from
"../../../common/constants/system-config.constant"
;
import
{
AppError
}
from
"../../../common/errors/app-error"
;
import
{
ERROR_CODE
}
from
"../../../common/errors/error-code"
;
describe
(
"SystemConfigService"
,
()
=>
{
let
service
:
SystemConfigService
;
let
mockRepo
:
jest
.
Mocked
<
SystemConfigRepository
>
;
let
mockAudit
:
jest
.
Mocked
<
AuditLogService
>
;
beforeEach
(()
=>
{
mockRepo
=
{
findAll
:
jest
.
fn
(),
findByKey
:
jest
.
fn
(),
findPublicConfigs
:
jest
.
fn
(),
create
:
jest
.
fn
(),
update
:
jest
.
fn
(),
delete
:
jest
.
fn
(),
ensureDefault
:
jest
.
fn
(),
}
as
unknown
as
jest
.
Mocked
<
SystemConfigRepository
>
;
mockAudit
=
{
log
:
jest
.
fn
().
mockResolvedValue
(
undefined
),
findAll
:
jest
.
fn
(),
}
as
unknown
as
jest
.
Mocked
<
AuditLogService
>
;
service
=
new
SystemConfigService
(
mockRepo
,
mockAudit
);
});
describe
(
"get & Level 1 Cache"
,
()
=>
{
it
(
"should fetch from repository on cache miss and cache the result"
,
async
()
=>
{
const
mockRecord
=
{
id
:
"1"
,
key
:
"app.name"
,
value
:
"Custom App"
,
description
:
"Test"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
};
mockRepo
.
findByKey
.
mockResolvedValueOnce
(
mockRecord
);
// Miss: queries repo
const
val1
=
await
service
.
get
(
"app.name"
);
expect
(
val1
).
toBe
(
"Custom App"
);
expect
(
mockRepo
.
findByKey
).
toHaveBeenCalledTimes
(
1
);
// Hit: serves from in-memory cache without calling repo again
const
val2
=
await
service
.
get
(
"app.name"
);
expect
(
val2
).
toBe
(
"Custom App"
);
expect
(
mockRepo
.
findByKey
).
toHaveBeenCalledTimes
(
1
);
});
it
(
"should return defaultValue when key does not exist"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
(
null
);
const
val
=
await
service
.
get
(
"non_existent_key"
,
"default_val"
);
expect
(
val
).
toBe
(
"default_val"
);
});
});
describe
(
"isFeatureEnabled"
,
()
=>
{
it
(
"should return true when value is boolean true"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
({
id
:
"1"
,
key
:
"feature.registration.enabled"
,
value
:
true
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
const
enabled
=
await
service
.
isFeatureEnabled
(
"feature.registration.enabled"
,
);
expect
(
enabled
).
toBe
(
true
);
});
it
(
"should return false when value is boolean false"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
({
id
:
"2"
,
key
:
"feature.ai.enabled"
,
value
:
false
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
const
enabled
=
await
service
.
isFeatureEnabled
(
"feature.ai.enabled"
);
expect
(
enabled
).
toBe
(
false
);
});
it
(
"should handle string boolean values ('true' / 'false')"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
({
id
:
"3"
,
key
:
"feature.flag.string"
,
value
:
"true"
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
const
enabled
=
await
service
.
isFeatureEnabled
(
"feature.flag.string"
);
expect
(
enabled
).
toBe
(
true
);
});
it
(
"should fallback to default value when flag not found"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
(
null
);
const
enabled
=
await
service
.
isFeatureEnabled
(
"missing.flag"
,
true
);
expect
(
enabled
).
toBe
(
true
);
});
});
describe
(
"getPublicConfigs"
,
()
=>
{
it
(
"should return public configs list and dictionary map"
,
async
()
=>
{
mockRepo
.
findPublicConfigs
.
mockResolvedValueOnce
([
{
id
:
"1"
,
key
:
"app.name"
,
value
:
"DataCrawler"
,
description
:
"App Name"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
},
{
id
:
"2"
,
key
:
"feature.registration.enabled"
,
value
:
true
,
description
:
"Registration Flag"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
},
]);
const
res
=
await
service
.
getPublicConfigs
();
expect
(
res
.
configs
).
toHaveLength
(
2
);
expect
(
res
.
map
).
toEqual
({
"app.name"
:
"DataCrawler"
,
"feature.registration.enabled"
:
true
,
});
// Second call should be served from memory cache
const
res2
=
await
service
.
getPublicConfigs
();
expect
(
res2
).
toEqual
(
res
);
expect
(
mockRepo
.
findPublicConfigs
).
toHaveBeenCalledTimes
(
1
);
});
});
describe
(
"create"
,
()
=>
{
it
(
"should create new config, clear cache and log audit action"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
(
null
);
mockRepo
.
create
.
mockResolvedValueOnce
({
id
:
"uuid-1"
,
key
:
"feature.new_module"
,
value
:
true
,
description
:
"New module flag"
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
const
result
=
await
service
.
create
(
{
key
:
"feature.new_module"
,
value
:
true
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
},
{
actorId
:
"admin-id"
},
);
expect
(
result
.
key
).
toBe
(
"feature.new_module"
);
expect
(
mockRepo
.
create
).
toHaveBeenCalled
();
expect
(
mockAudit
.
log
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
userId
:
"admin-id"
,
action
:
"SYSTEM_CONFIG_CREATED"
,
}),
);
});
it
(
"should throw DUPLICATE_ENTRY when key already exists"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
({
id
:
"uuid-1"
,
key
:
"app.name"
,
value
:
"My App"
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
await
expect
(
service
.
create
({
key
:
"app.name"
,
value
:
"Conflict"
,
}),
).
rejects
.
toThrow
(
AppError
);
});
});
describe
(
"update"
,
()
=>
{
it
(
"should update config, clear cache and log audit action"
,
async
()
=>
{
const
existing
=
{
id
:
"uuid-1"
,
key
:
"app.name"
,
value
:
"Old App"
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
};
mockRepo
.
findByKey
.
mockResolvedValueOnce
(
existing
);
mockRepo
.
update
.
mockResolvedValueOnce
({
...
existing
,
value
:
"New App"
,
});
const
updated
=
await
service
.
update
(
"app.name"
,
{
value
:
"New App"
},
{
actorId
:
"admin-id"
},
);
expect
(
updated
.
value
).
toBe
(
"New App"
);
expect
(
mockRepo
.
update
).
toHaveBeenCalledWith
(
"app.name"
,
{
value
:
"New App"
,
});
expect
(
mockAudit
.
log
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
userId
:
"admin-id"
,
action
:
"SYSTEM_CONFIG_UPDATED"
,
}),
);
});
it
(
"should throw NOT_FOUND when updating non-existent config"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
(
null
);
await
expect
(
service
.
update
(
"not.found"
,
{
value
:
123
}),
).
rejects
.
toThrow
(
AppError
);
});
});
describe
(
"toggleFeature"
,
()
=>
{
it
(
"should toggle boolean feature flag from true to false"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
({
id
:
"uuid-1"
,
key
:
"feature.ai.enabled"
,
value
:
true
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
mockRepo
.
update
.
mockResolvedValueOnce
({
id
:
"uuid-1"
,
key
:
"feature.ai.enabled"
,
value
:
false
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
FEATURE_FLAG
,
isPublic
:
true
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
const
toggled
=
await
service
.
toggleFeature
(
"feature.ai.enabled"
,
{
actorId
:
"admin-1"
,
});
expect
(
toggled
.
value
).
toBe
(
false
);
expect
(
mockRepo
.
update
).
toHaveBeenCalledWith
(
"feature.ai.enabled"
,
{
value
:
false
,
});
expect
(
mockAudit
.
log
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
userId
:
"admin-1"
,
action
:
"SYSTEM_CONFIG_TOGGLED"
,
}),
);
});
it
(
"should throw VALIDATION_ERROR when trying to toggle non-boolean config"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
({
id
:
"uuid-1"
,
key
:
"rate_limit.max_requests_per_minute"
,
value
:
60
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
SECURITY
,
isPublic
:
false
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
await
expect
(
service
.
toggleFeature
(
"rate_limit.max_requests_per_minute"
),
).
rejects
.
toThrow
(
expect
.
objectContaining
({
code
:
ERROR_CODE
.
VALIDATION_ERROR
,
}),
);
});
});
describe
(
"delete"
,
()
=>
{
it
(
"should delete existing config and log audit action"
,
async
()
=>
{
mockRepo
.
findByKey
.
mockResolvedValueOnce
({
id
:
"uuid-1"
,
key
:
"temp.config"
,
value
:
"test"
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
mockRepo
.
delete
.
mockResolvedValueOnce
({
id
:
"uuid-1"
,
key
:
"temp.config"
,
value
:
"test"
,
description
:
null
,
category
:
SYSTEM_CONFIG_CATEGORY
.
GENERAL
,
isPublic
:
false
,
createdAt
:
new
Date
(),
updatedAt
:
new
Date
(),
});
const
res
=
await
service
.
delete
(
"temp.config"
,
{
actorId
:
"admin-1"
});
expect
(
res
.
success
).
toBe
(
true
);
expect
(
mockRepo
.
delete
).
toHaveBeenCalledWith
(
"temp.config"
);
expect
(
mockAudit
.
log
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
userId
:
"admin-1"
,
action
:
"SYSTEM_CONFIG_DELETED"
,
}),
);
});
});
describe
(
"ensureDefaultConfigs"
,
()
=>
{
it
(
"should ensure all default configs are inserted"
,
async
()
=>
{
mockRepo
.
ensureDefault
.
mockResolvedValue
({}
as
any
);
await
service
.
ensureDefaultConfigs
();
expect
(
mockRepo
.
ensureDefault
).
toHaveBeenCalledTimes
(
DEFAULT_SYSTEM_CONFIGS
.
length
,
);
});
});
});
src/modules/system-config/system-config.controller.ts
0 → 100644
View file @
0b96ce87
import
{
Request
,
Response
,
NextFunction
}
from
"express"
;
import
{
systemConfigService
}
from
"./system-config.service"
;
import
{
CreateSystemConfigDto
,
UpdateSystemConfigDto
,
SystemConfigQueryDto
,
}
from
"./system-config.dto"
;
export
class
SystemConfigController
{
private
readonly
service
=
systemConfigService
;
getPublic
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
):
Promise
<
void
>
=>
{
try
{
const
result
=
await
this
.
service
.
getPublicConfigs
();
res
.
json
({
success
:
true
,
data
:
result
,
});
}
catch
(
error
)
{
next
(
error
);
}
};
findAll
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
):
Promise
<
void
>
=>
{
try
{
const
query
=
req
.
query
as
SystemConfigQueryDto
;
const
result
=
await
this
.
service
.
findAll
(
query
);
res
.
json
({
success
:
true
,
data
:
result
,
});
}
catch
(
error
)
{
next
(
error
);
}
};
findByKey
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
):
Promise
<
void
>
=>
{
try
{
const
{
key
}
=
req
.
params
;
const
result
=
await
this
.
service
.
findByKey
(
key
);
res
.
json
({
success
:
true
,
data
:
result
,
});
}
catch
(
error
)
{
next
(
error
);
}
};
create
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
):
Promise
<
void
>
=>
{
try
{
const
dto
:
CreateSystemConfigDto
=
req
.
body
;
const
result
=
await
this
.
service
.
create
(
dto
,
{
actorId
:
req
.
user
.
id
,
ipAddress
:
req
.
ip
,
userAgent
:
req
.
headers
[
"user-agent"
]
as
string
,
});
res
.
status
(
201
).
json
({
success
:
true
,
data
:
result
,
message
:
"Tạo cấu hình mới thành công"
,
});
}
catch
(
error
)
{
next
(
error
);
}
};
update
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
):
Promise
<
void
>
=>
{
try
{
const
{
key
}
=
req
.
params
;
const
dto
:
UpdateSystemConfigDto
=
req
.
body
;
const
result
=
await
this
.
service
.
update
(
key
,
dto
,
{
actorId
:
req
.
user
.
id
,
ipAddress
:
req
.
ip
,
userAgent
:
req
.
headers
[
"user-agent"
]
as
string
,
});
res
.
json
({
success
:
true
,
data
:
result
,
message
:
"Cập nhật cấu hình thành công"
,
});
}
catch
(
error
)
{
next
(
error
);
}
};
toggleFeature
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
):
Promise
<
void
>
=>
{
try
{
const
{
key
}
=
req
.
params
;
const
result
=
await
this
.
service
.
toggleFeature
(
key
,
{
actorId
:
req
.
user
.
id
,
ipAddress
:
req
.
ip
,
userAgent
:
req
.
headers
[
"user-agent"
]
as
string
,
});
res
.
json
({
success
:
true
,
data
:
result
,
message
:
`Đã
${
result
.
value
?
"bật"
:
"tắt"
}
tính năng thành công`
,
});
}
catch
(
error
)
{
next
(
error
);
}
};
delete
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
):
Promise
<
void
>
=>
{
try
{
const
{
key
}
=
req
.
params
;
await
this
.
service
.
delete
(
key
,
{
actorId
:
req
.
user
.
id
,
ipAddress
:
req
.
ip
,
userAgent
:
req
.
headers
[
"user-agent"
]
as
string
,
});
res
.
json
({
success
:
true
,
message
:
"Xóa cấu hình thành công"
,
});
}
catch
(
error
)
{
next
(
error
);
}
};
}
src/modules/system-config/system-config.dto.ts
0 → 100644
View file @
0b96ce87
import
{
SystemConfigCategory
}
from
"../../common/constants/system-config.constant"
;
export
interface
CreateSystemConfigDto
{
key
:
string
;
value
:
unknown
;
description
?:
string
;
category
?:
SystemConfigCategory
;
isPublic
?:
boolean
;
}
export
interface
UpdateSystemConfigDto
{
value
?:
unknown
;
description
?:
string
;
category
?:
SystemConfigCategory
;
isPublic
?:
boolean
;
}
export
interface
SystemConfigQueryDto
{
category
?:
SystemConfigCategory
;
search
?:
string
;
isPublic
?:
boolean
|
string
;
page
?:
number
|
string
;
limit
?:
number
|
string
;
}
export
interface
SystemConfigResponseDto
{
id
:
string
;
key
:
string
;
value
:
unknown
;
description
:
string
|
null
;
category
:
string
;
isPublic
:
boolean
;
createdAt
:
Date
;
updatedAt
:
Date
;
}
export
interface
PublicConfigsResponseDto
{
configs
:
Array
<
{
key
:
string
;
value
:
unknown
;
category
:
string
;
description
:
string
|
null
;
}
>
;
map
:
Record
<
string
,
unknown
>
;
}
export
interface
SystemConfigEventPayload
{
key
?:
string
;
action
:
"create"
|
"update"
|
"toggle"
|
"delete"
|
"invalidate"
;
timestamp
:
number
;
}
src/modules/system-config/system-config.repository.ts
0 → 100644
View file @
0b96ce87
import
{
prisma
}
from
"../../database/prisma.client"
;
import
{
Prisma
}
from
"@prisma/client"
;
import
{
CreateSystemConfigDto
,
UpdateSystemConfigDto
,
SystemConfigQueryDto
,
}
from
"./system-config.dto"
;
import
{
DefaultSystemConfigItem
}
from
"../../common/constants/system-config.constant"
;
export
class
SystemConfigRepository
{
async
findAll
(
query
:
SystemConfigQueryDto
=
{})
{
const
where
:
Prisma
.
SystemConfigWhereInput
=
{};
if
(
query
.
category
)
{
where
.
category
=
query
.
category
;
}
if
(
query
.
isPublic
!==
undefined
)
{
where
.
isPublic
=
typeof
query
.
isPublic
===
"boolean"
?
query
.
isPublic
:
query
.
isPublic
===
"true"
;
}
if
(
query
.
search
)
{
where
.
OR
=
[
{
key
:
{
contains
:
query
.
search
,
mode
:
"insensitive"
}
},
{
description
:
{
contains
:
query
.
search
,
mode
:
"insensitive"
}
},
];
}
const
page
=
Math
.
max
(
1
,
Number
(
query
.
page
)
||
1
);
const
limit
=
Math
.
min
(
Math
.
max
(
1
,
Number
(
query
.
limit
)
||
20
),
100
);
const
skip
=
(
page
-
1
)
*
limit
;
const
[
items
,
total
]
=
await
Promise
.
all
([
prisma
.
systemConfig
.
findMany
({
where
,
orderBy
:
[{
category
:
"asc"
},
{
key
:
"asc"
}],
skip
,
take
:
limit
,
}),
prisma
.
systemConfig
.
count
({
where
}),
]);
return
{
items
,
total
,
page
,
limit
,
};
}
async
findByKey
(
key
:
string
)
{
return
prisma
.
systemConfig
.
findUnique
({
where
:
{
key
},
});
}
async
findPublicConfigs
()
{
return
prisma
.
systemConfig
.
findMany
({
where
:
{
isPublic
:
true
},
orderBy
:
{
key
:
"asc"
},
});
}
async
create
(
data
:
CreateSystemConfigDto
)
{
return
prisma
.
systemConfig
.
create
({
data
:
{
key
:
data
.
key
,
value
:
data
.
value
as
Prisma
.
InputJsonValue
,
description
:
data
.
description
??
null
,
category
:
data
.
category
,
isPublic
:
data
.
isPublic
??
false
,
},
});
}
async
update
(
key
:
string
,
data
:
UpdateSystemConfigDto
)
{
const
updatePayload
:
Prisma
.
SystemConfigUpdateInput
=
{};
if
(
data
.
value
!==
undefined
)
{
updatePayload
.
value
=
data
.
value
as
Prisma
.
InputJsonValue
;
}
if
(
data
.
description
!==
undefined
)
{
updatePayload
.
description
=
data
.
description
;
}
if
(
data
.
category
!==
undefined
)
{
updatePayload
.
category
=
data
.
category
;
}
if
(
data
.
isPublic
!==
undefined
)
{
updatePayload
.
isPublic
=
data
.
isPublic
;
}
return
prisma
.
systemConfig
.
update
({
where
:
{
key
},
data
:
updatePayload
,
});
}
async
delete
(
key
:
string
)
{
return
prisma
.
systemConfig
.
delete
({
where
:
{
key
},
});
}
async
ensureDefault
(
item
:
DefaultSystemConfigItem
)
{
return
prisma
.
systemConfig
.
upsert
({
where
:
{
key
:
item
.
key
},
update
:
{
description
:
item
.
description
??
null
,
category
:
item
.
category
,
isPublic
:
item
.
isPublic
,
},
create
:
{
key
:
item
.
key
,
value
:
item
.
value
as
Prisma
.
InputJsonValue
,
description
:
item
.
description
??
null
,
category
:
item
.
category
,
isPublic
:
item
.
isPublic
,
},
});
}
}
src/modules/system-config/system-config.route.ts
0 → 100644
View file @
0b96ce87
import
{
Router
}
from
"express"
;
import
{
SystemConfigController
}
from
"./system-config.controller"
;
import
{
authMiddleware
}
from
"../../middlewares/auth.middleware"
;
import
{
requirePermission
}
from
"../../middlewares/permission.middleware"
;
import
{
validate
,
validateQuery
,
validateParams
,
}
from
"../../middlewares/validate.middleware"
;
import
{
createSystemConfigSchema
,
updateSystemConfigSchema
,
systemConfigKeyParamSchema
,
systemConfigQuerySchema
,
}
from
"./system-config.validation"
;
import
{
PERMISSIONS
}
from
"../../common/constants/permission.constant"
;
const
router
=
Router
();
const
controller
=
new
SystemConfigController
();
// 1. GET /api/v1/system/public (Public client access)
router
.
get
(
"/public"
,
controller
.
getPublic
);
// 2. GET /api/v1/system/configs (Admin list configs with search and category filter)
router
.
get
(
"/configs"
,
authMiddleware
,
requirePermission
(
PERMISSIONS
.
SYSTEM_CONFIG_READ
),
validateQuery
(
systemConfigQuerySchema
),
controller
.
findAll
,
);
// 3. GET /api/v1/system/configs/:key (Get single config detail)
router
.
get
(
"/configs/:key"
,
authMiddleware
,
requirePermission
(
PERMISSIONS
.
SYSTEM_CONFIG_READ
),
validateParams
(
systemConfigKeyParamSchema
),
controller
.
findByKey
,
);
// 4. POST /api/v1/system/configs (Create new config)
router
.
post
(
"/configs"
,
authMiddleware
,
requirePermission
(
PERMISSIONS
.
SYSTEM_CONFIG_MANAGE
),
validate
(
createSystemConfigSchema
),
controller
.
create
,
);
// 5. PUT /api/v1/system/configs/:key (Update config)
router
.
put
(
"/configs/:key"
,
authMiddleware
,
requirePermission
(
PERMISSIONS
.
SYSTEM_CONFIG_MANAGE
),
validateParams
(
systemConfigKeyParamSchema
),
validate
(
updateSystemConfigSchema
),
controller
.
update
,
);
// 6. PATCH /api/v1/system/features/:key/toggle (Quick toggle for boolean Feature Flag)
router
.
patch
(
"/features/:key/toggle"
,
authMiddleware
,
requirePermission
(
PERMISSIONS
.
SYSTEM_CONFIG_MANAGE
),
validateParams
(
systemConfigKeyParamSchema
),
controller
.
toggleFeature
,
);
// 7. DELETE /api/v1/system/configs/:key (Delete config)
router
.
delete
(
"/configs/:key"
,
authMiddleware
,
requirePermission
(
PERMISSIONS
.
SYSTEM_CONFIG_MANAGE
),
validateParams
(
systemConfigKeyParamSchema
),
controller
.
delete
,
);
export
default
router
;
src/modules/system-config/system-config.service.ts
0 → 100644
View file @
0b96ce87
import
{
SystemConfigRepository
}
from
"./system-config.repository"
;
import
{
CreateSystemConfigDto
,
UpdateSystemConfigDto
,
SystemConfigQueryDto
,
PublicConfigsResponseDto
,
SystemConfigEventPayload
,
SystemConfigResponseDto
,
}
from
"./system-config.dto"
;
import
{
DEFAULT_SYSTEM_CONFIGS
,
SYSTEM_CONFIG_CACHE_TTL_MS
,
SYSTEM_CONFIG_EVENTS_CHANNEL
,
}
from
"../../common/constants/system-config.constant"
;
import
{
AUDIT_ACTIONS
}
from
"../../common/constants/audit-action.constant"
;
import
{
AppError
}
from
"../../common/errors/app-error"
;
import
{
ERROR_CODE
}
from
"../../common/errors/error-code"
;
import
{
AuditLogService
}
from
"../audit-logs/audit-log.service"
;
import
{
getRedisPublisher
,
getRedisSubscriber
,
}
from
"../../common/redis/redis-pubsub"
;
interface
AuditContext
{
actorId
?:
string
;
ipAddress
?:
string
;
userAgent
?:
string
;
}
interface
CacheEntry
{
value
:
unknown
;
expiresAt
:
number
;
config
:
SystemConfigResponseDto
;
}
export
class
SystemConfigService
{
private
cache
=
new
Map
<
string
,
CacheEntry
>
();
private
publicConfigsCache
:
{
data
:
PublicConfigsResponseDto
;
expiresAt
:
number
;
}
|
null
=
null
;
private
isSubscriberInitialized
=
false
;
constructor
(
private
readonly
repository
=
new
SystemConfigRepository
(),
private
readonly
auditLogService
=
new
AuditLogService
(),
)
{}
/**
* Khởi tạo Redis Subscriber để lắng nghe thông điệp xóa cache từ các node khác
*/
public
initRedisSubscriber
():
void
{
if
(
this
.
isSubscriberInitialized
)
return
;
const
subscriber
=
getRedisSubscriber
();
if
(
!
subscriber
)
return
;
try
{
subscriber
.
subscribe
(
SYSTEM_CONFIG_EVENTS_CHANNEL
,
(
err
)
=>
{
if
(
err
)
{
console
.
warn
(
"[SystemConfig:RedisSub] Failed to subscribe:"
,
err
.
message
,
);
}
else
{
this
.
isSubscriberInitialized
=
true
;
console
.
log
(
`[SystemConfig] Subscribed to Redis channel:
${
SYSTEM_CONFIG_EVENTS_CHANNEL
}
`
,
);
}
});
subscriber
.
on
(
"message"
,
(
channel
,
message
)
=>
{
if
(
channel
===
SYSTEM_CONFIG_EVENTS_CHANNEL
)
{
try
{
const
event
:
SystemConfigEventPayload
=
JSON
.
parse
(
message
);
this
.
clearLocalCache
(
event
.
key
);
}
catch
{
this
.
clearLocalCache
();
}
}
});
}
catch
(
error
)
{
console
.
warn
(
"[SystemConfig:RedisSub] Subscriber setup failed:"
,
error
);
}
}
/**
* Phát thông điệp xóa cache toàn cluster qua Redis Pub/Sub
*/
public
async
publishInvalidation
(
key
?:
string
,
action
:
SystemConfigEventPayload
[
"action"
]
=
"invalidate"
,
):
Promise
<
void
>
{
this
.
clearLocalCache
(
key
);
const
publisher
=
getRedisPublisher
();
if
(
!
publisher
)
return
;
try
{
const
payload
:
SystemConfigEventPayload
=
{
key
,
action
,
timestamp
:
Date
.
now
(),
};
await
publisher
.
publish
(
SYSTEM_CONFIG_EVENTS_CHANNEL
,
JSON
.
stringify
(
payload
),
);
}
catch
{
// Degraded mode: local cache already invalidated
}
}
/**
* Xóa RAM cache cục bộ (Level 1)
*/
public
clearLocalCache
(
key
?:
string
):
void
{
if
(
key
)
{
this
.
cache
.
delete
(
key
);
}
else
{
this
.
cache
.
clear
();
}
this
.
publicConfigsCache
=
null
;
}
/**
* Level 1 Cache Getter: Truy xuất giá trị cấu hình theo key với tốc độ < 0.1ms
*/
async
get
<
T
=
unknown
>
(
key
:
string
,
defaultValue
?:
T
):
Promise
<
T
>
{
const
cached
=
this
.
cache
.
get
(
key
);
if
(
cached
&&
Date
.
now
()
<
cached
.
expiresAt
)
{
return
cached
.
value
as
T
;
}
const
record
=
await
this
.
repository
.
findByKey
(
key
);
if
(
!
record
)
{
return
defaultValue
as
T
;
}
this
.
cache
.
set
(
key
,
{
value
:
record
.
value
,
expiresAt
:
Date
.
now
()
+
SYSTEM_CONFIG_CACHE_TTL_MS
,
config
:
record
as
SystemConfigResponseDto
,
});
return
record
.
value
as
T
;
}
/**
* Kiểm tra nhanh Feature Flag dạng boolean cho các service nội bộ
*/
async
isFeatureEnabled
(
key
:
string
,
defaultValue
:
boolean
=
false
,
):
Promise
<
boolean
>
{
const
value
=
await
this
.
get
(
key
,
defaultValue
);
if
(
typeof
value
===
"boolean"
)
{
return
value
;
}
if
(
value
===
"true"
||
value
===
1
||
value
===
"1"
)
{
return
true
;
}
if
(
value
===
"false"
||
value
===
0
||
value
===
"0"
)
{
return
false
;
}
return
Boolean
(
value
);
}
/**
* Lấy danh sách cấu hình công khai (isPublic: true) cho client
*/
async
getPublicConfigs
():
Promise
<
PublicConfigsResponseDto
>
{
if
(
this
.
publicConfigsCache
&&
Date
.
now
()
<
this
.
publicConfigsCache
.
expiresAt
)
{
return
this
.
publicConfigsCache
.
data
;
}
const
records
=
await
this
.
repository
.
findPublicConfigs
();
const
configs
=
records
.
map
((
r
)
=>
({
key
:
r
.
key
,
value
:
r
.
value
,
category
:
r
.
category
,
description
:
r
.
description
,
}));
const
map
:
Record
<
string
,
unknown
>
=
{};
for
(
const
item
of
configs
)
{
map
[
item
.
key
]
=
item
.
value
;
}
const
result
:
PublicConfigsResponseDto
=
{
configs
,
map
};
this
.
publicConfigsCache
=
{
data
:
result
,
expiresAt
:
Date
.
now
()
+
SYSTEM_CONFIG_CACHE_TTL_MS
,
};
return
result
;
}
/**
* Lấy danh sách cấu hình hệ thống (Admin)
*/
async
findAll
(
query
:
SystemConfigQueryDto
=
{})
{
return
this
.
repository
.
findAll
(
query
);
}
/**
* Lấy chi tiết 1 cấu hình theo key
*/
async
findByKey
(
key
:
string
):
Promise
<
SystemConfigResponseDto
>
{
const
cached
=
this
.
cache
.
get
(
key
);
if
(
cached
&&
Date
.
now
()
<
cached
.
expiresAt
)
{
return
cached
.
config
;
}
const
config
=
await
this
.
repository
.
findByKey
(
key
);
if
(
!
config
)
{
throw
new
AppError
(
`Không tìm thấy cấu hình với khóa [
${
key
}
]`
,
404
,
ERROR_CODE
.
NOT_FOUND
,
);
}
const
dto
=
config
as
SystemConfigResponseDto
;
this
.
cache
.
set
(
key
,
{
value
:
config
.
value
,
expiresAt
:
Date
.
now
()
+
SYSTEM_CONFIG_CACHE_TTL_MS
,
config
:
dto
,
});
return
dto
;
}
/**
* Tạo cấu hình mới
*/
async
create
(
data
:
CreateSystemConfigDto
,
context
?:
AuditContext
,
):
Promise
<
SystemConfigResponseDto
>
{
const
existing
=
await
this
.
repository
.
findByKey
(
data
.
key
);
if
(
existing
)
{
throw
new
AppError
(
`Khóa cấu hình [
${
data
.
key
}
] đã tồn tại`
,
409
,
ERROR_CODE
.
DUPLICATE_ENTRY
,
);
}
const
created
=
await
this
.
repository
.
create
(
data
);
await
this
.
publishInvalidation
(
data
.
key
,
"create"
);
await
this
.
auditLogService
.
log
({
userId
:
context
?.
actorId
,
action
:
AUDIT_ACTIONS
.
SYSTEM_CONFIG_CREATED
,
details
:
{
key
:
created
.
key
,
category
:
created
.
category
,
isPublic
:
created
.
isPublic
,
},
ipAddress
:
context
?.
ipAddress
,
userAgent
:
context
?.
userAgent
,
});
return
created
as
SystemConfigResponseDto
;
}
/**
* Cập nhật cấu hình
*/
async
update
(
key
:
string
,
data
:
UpdateSystemConfigDto
,
context
?:
AuditContext
,
):
Promise
<
SystemConfigResponseDto
>
{
const
existing
=
await
this
.
repository
.
findByKey
(
key
);
if
(
!
existing
)
{
throw
new
AppError
(
`Không tìm thấy cấu hình với khóa [
${
key
}
]`
,
404
,
ERROR_CODE
.
NOT_FOUND
,
);
}
const
updated
=
await
this
.
repository
.
update
(
key
,
data
);
await
this
.
publishInvalidation
(
key
,
"update"
);
await
this
.
auditLogService
.
log
({
userId
:
context
?.
actorId
,
action
:
AUDIT_ACTIONS
.
SYSTEM_CONFIG_UPDATED
,
details
:
{
key
,
oldValue
:
existing
.
value
,
newValue
:
updated
.
value
,
category
:
updated
.
category
,
isPublic
:
updated
.
isPublic
,
},
ipAddress
:
context
?.
ipAddress
,
userAgent
:
context
?.
userAgent
,
});
return
updated
as
SystemConfigResponseDto
;
}
/**
* Bật/tắt nhanh Feature Flag dạng boolean
*/
async
toggleFeature
(
key
:
string
,
context
?:
AuditContext
,
):
Promise
<
SystemConfigResponseDto
>
{
const
existing
=
await
this
.
repository
.
findByKey
(
key
);
if
(
!
existing
)
{
throw
new
AppError
(
`Không tìm thấy Feature Flag với khóa [
${
key
}
]`
,
404
,
ERROR_CODE
.
NOT_FOUND
,
);
}
let
currentBool
:
boolean
;
if
(
typeof
existing
.
value
===
"boolean"
)
{
currentBool
=
existing
.
value
;
}
else
if
(
existing
.
value
===
"true"
||
existing
.
value
===
"1"
)
{
currentBool
=
true
;
}
else
if
(
existing
.
value
===
"false"
||
existing
.
value
===
"0"
)
{
currentBool
=
false
;
}
else
{
throw
new
AppError
(
`Cấu hình [
${
key
}
] không phải là cờ tính năng (boolean)`
,
400
,
ERROR_CODE
.
VALIDATION_ERROR
,
);
}
const
nextValue
=
!
currentBool
;
const
updated
=
await
this
.
repository
.
update
(
key
,
{
value
:
nextValue
});
await
this
.
publishInvalidation
(
key
,
"toggle"
);
await
this
.
auditLogService
.
log
({
userId
:
context
?.
actorId
,
action
:
AUDIT_ACTIONS
.
SYSTEM_CONFIG_TOGGLED
,
details
:
{
key
,
previousState
:
currentBool
,
newState
:
nextValue
,
},
ipAddress
:
context
?.
ipAddress
,
userAgent
:
context
?.
userAgent
,
});
return
updated
as
SystemConfigResponseDto
;
}
/**
* Xóa cấu hình
*/
async
delete
(
key
:
string
,
context
?:
AuditContext
,
):
Promise
<
{
success
:
boolean
}
>
{
const
existing
=
await
this
.
repository
.
findByKey
(
key
);
if
(
!
existing
)
{
throw
new
AppError
(
`Không tìm thấy cấu hình với khóa [
${
key
}
]`
,
404
,
ERROR_CODE
.
NOT_FOUND
,
);
}
await
this
.
repository
.
delete
(
key
);
await
this
.
publishInvalidation
(
key
,
"delete"
);
await
this
.
auditLogService
.
log
({
userId
:
context
?.
actorId
,
action
:
AUDIT_ACTIONS
.
SYSTEM_CONFIG_DELETED
,
details
:
{
key
,
category
:
existing
.
category
,
},
ipAddress
:
context
?.
ipAddress
,
userAgent
:
context
?.
userAgent
,
});
return
{
success
:
true
};
}
/**
* Khởi tạo cấu hình mặc định nếu chưa tồn tại trong Database
*/
async
ensureDefaultConfigs
():
Promise
<
void
>
{
for
(
const
item
of
DEFAULT_SYSTEM_CONFIGS
)
{
try
{
await
this
.
repository
.
ensureDefault
(
item
);
}
catch
(
err
)
{
console
.
warn
(
`[SystemConfig] ensureDefault error for
${
item
.
key
}
:`
,
err
,
);
}
}
this
.
clearLocalCache
();
}
}
export
const
systemConfigService
=
new
SystemConfigService
();
src/modules/system-config/system-config.validation.ts
0 → 100644
View file @
0b96ce87
import
{
z
}
from
"zod"
;
import
{
SYSTEM_CONFIG_CATEGORY
}
from
"../../common/constants/system-config.constant"
;
export
const
createSystemConfigSchema
=
z
.
object
({
key
:
z
.
string
({
required_error
:
"Khóa cấu hình là bắt buộc"
})
.
min
(
2
,
"Khóa cấu hình phải có ít nhất 2 ký tự"
)
.
max
(
100
,
"Khóa cấu hình tối đa 100 ký tự"
)
.
regex
(
/^
[
a-zA-Z0-9_.-
]
+$/
,
"Khóa cấu hình chỉ được chứa chữ cái, chữ số, dấu chấm (.), gạch dưới (_) và gạch ngang (-)"
,
),
value
:
z
.
any
()
.
refine
((
val
)
=>
val
!==
undefined
,
{
message
:
"Giá trị cấu hình không được để trống"
,
}),
description
:
z
.
string
().
max
(
500
,
"Mô tả tối đa 500 ký tự"
).
optional
().
nullable
(),
category
:
z
.
nativeEnum
(
SYSTEM_CONFIG_CATEGORY
)
.
optional
()
.
default
(
SYSTEM_CONFIG_CATEGORY
.
GENERAL
),
isPublic
:
z
.
boolean
().
optional
().
default
(
false
),
});
export
const
updateSystemConfigSchema
=
z
.
object
({
value
:
z
.
any
().
optional
(),
description
:
z
.
string
().
max
(
500
,
"Mô tả tối đa 500 ký tự"
).
optional
().
nullable
(),
category
:
z
.
nativeEnum
(
SYSTEM_CONFIG_CATEGORY
).
optional
(),
isPublic
:
z
.
boolean
().
optional
(),
})
.
refine
(
(
data
)
=>
data
.
value
!==
undefined
||
data
.
description
!==
undefined
||
data
.
category
!==
undefined
||
data
.
isPublic
!==
undefined
,
{
message
:
"Phải cung cấp ít nhất một trường để cập nhật"
,
},
);
export
const
systemConfigKeyParamSchema
=
z
.
object
({
key
:
z
.
string
().
min
(
1
,
"Khóa cấu hình không được để trống"
),
});
export
const
systemConfigQuerySchema
=
z
.
object
({
category
:
z
.
nativeEnum
(
SYSTEM_CONFIG_CATEGORY
).
optional
(),
search
:
z
.
string
().
optional
(),
isPublic
:
z
.
union
([
z
.
boolean
(),
z
.
enum
([
"true"
,
"false"
])])
.
optional
(),
page
:
z
.
union
([
z
.
number
(),
z
.
string
()])
.
optional
()
.
transform
((
val
)
=>
{
if
(
val
===
undefined
)
return
1
;
const
parsed
=
typeof
val
===
"string"
?
parseInt
(
val
,
10
)
:
val
;
return
isNaN
(
parsed
)
||
parsed
<
1
?
1
:
parsed
;
}),
limit
:
z
.
union
([
z
.
number
(),
z
.
string
()])
.
optional
()
.
transform
((
val
)
=>
{
if
(
val
===
undefined
)
return
20
;
const
parsed
=
typeof
val
===
"string"
?
parseInt
(
val
,
10
)
:
val
;
return
isNaN
(
parsed
)
||
parsed
<
1
?
20
:
Math
.
min
(
parsed
,
100
);
}),
});
src/modules/users/user.repository.ts
View file @
0b96ce87
...
@@ -4,6 +4,7 @@ import { UserQueryDto } from "./user.dto";
...
@@ -4,6 +4,7 @@ import { UserQueryDto } from "./user.dto";
import
{
envConfig
}
from
"../../config/env.config"
;
import
{
envConfig
}
from
"../../config/env.config"
;
import
{
ROLES
}
from
"../../common/constants/role.constant"
;
import
{
ROLES
}
from
"../../common/constants/role.constant"
;
import
{
SYSTEM_ROLE_SLUGS
}
from
"../../common/constants/system-role.constant"
;
import
{
SYSTEM_ROLE_SLUGS
}
from
"../../common/constants/system-role.constant"
;
import
{
systemConfigService
}
from
"../system-config/system-config.service"
;
export
class
UserRepository
{
export
class
UserRepository
{
async
findAll
(
query
:
UserQueryDto
=
{})
{
async
findAll
(
query
:
UserQueryDto
=
{})
{
...
@@ -94,7 +95,7 @@ export class UserRepository {
...
@@ -94,7 +95,7 @@ export class UserRepository {
});
});
}
}
create
(
data
:
{
async
create
(
data
:
{
email
:
string
;
email
:
string
;
passwordHash
:
string
;
passwordHash
:
string
;
fullName
?:
string
;
fullName
?:
string
;
...
@@ -104,6 +105,19 @@ export class UserRepository {
...
@@ -104,6 +105,19 @@ export class UserRepository {
maxJobsPerDayLimit
?:
number
;
maxJobsPerDayLimit
?:
number
;
maxConcurrentJobsLimit
?:
number
;
maxConcurrentJobsLimit
?:
number
;
}):
Promise
<
User
>
{
}):
Promise
<
User
>
{
const
defaultMaxPages
=
await
systemConfigService
.
get
<
number
>
(
"quota.user_max_pages"
,
envConfig
.
quota
.
defaultMaxPages
,
);
const
defaultMaxJobsPerDay
=
await
systemConfigService
.
get
<
number
>
(
"quota.user_max_jobs_per_day"
,
envConfig
.
quota
.
defaultMaxJobsPerDay
,
);
const
defaultMaxConcurrentJobs
=
await
systemConfigService
.
get
<
number
>
(
"quota.user_max_concurrent_jobs"
,
envConfig
.
quota
.
defaultMaxConcurrentJobs
,
);
return
prisma
.
user
.
create
({
return
prisma
.
user
.
create
({
data
:
{
data
:
{
email
:
data
.
email
,
email
:
data
.
email
,
...
@@ -111,12 +125,10 @@ export class UserRepository {
...
@@ -111,12 +125,10 @@ export class UserRepository {
fullName
:
data
.
fullName
,
fullName
:
data
.
fullName
,
avatarUrl
:
data
.
avatarUrl
,
avatarUrl
:
data
.
avatarUrl
,
role
:
data
.
role
??
ROLES
.
CRAWLER_USER
,
role
:
data
.
role
??
ROLES
.
CRAWLER_USER
,
maxPagesLimit
:
data
.
maxPagesLimit
??
envConfig
.
quota
.
defaultMaxPages
,
maxPagesLimit
:
data
.
maxPagesLimit
??
defaultMaxPages
,
maxJobsPerDayLimit
:
maxJobsPerDayLimit
:
data
.
maxJobsPerDayLimit
??
defaultMaxJobsPerDay
,
data
.
maxJobsPerDayLimit
??
envConfig
.
quota
.
defaultMaxJobsPerDay
,
maxConcurrentJobsLimit
:
maxConcurrentJobsLimit
:
data
.
maxConcurrentJobsLimit
??
data
.
maxConcurrentJobsLimit
??
defaultMaxConcurrentJobs
,
envConfig
.
quota
.
defaultMaxConcurrentJobs
,
},
},
});
});
}
}
...
...
src/queues/crawl.worker.processor.ts
View file @
0b96ce87
...
@@ -22,6 +22,7 @@ import { CRAWL_MODE } from "../common/constants/crawl-mode.constant";
...
@@ -22,6 +22,7 @@ import { CRAWL_MODE } from "../common/constants/crawl-mode.constant";
import
{
ASSET_TYPE
}
from
"../common/constants/asset-type.constant"
;
import
{
ASSET_TYPE
}
from
"../common/constants/asset-type.constant"
;
import
{
CRAWL_PAGE_STATUS
}
from
"../common/constants/crawl-page-status.constant"
;
import
{
CRAWL_PAGE_STATUS
}
from
"../common/constants/crawl-page-status.constant"
;
import
{
WEBHOOK_EVENT
}
from
"../common/constants/webhook.constant"
;
import
{
WEBHOOK_EVENT
}
from
"../common/constants/webhook.constant"
;
import
{
systemConfigService
}
from
"../modules/system-config/system-config.service"
;
// Lazy getters — instantiated on first use so Jest mocks replace constructors before creation
// Lazy getters — instantiated on first use so Jest mocks replace constructors before creation
const
getJobRepository
=
()
=>
new
CrawlJobRepository
();
const
getJobRepository
=
()
=>
new
CrawlJobRepository
();
const
getPageRepository
=
()
=>
new
CrawlPageRepository
();
const
getPageRepository
=
()
=>
new
CrawlPageRepository
();
...
@@ -704,20 +705,30 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -704,20 +705,30 @@ export async function processCrawlJob(job: Job): Promise<void> {
});
});
}
}
// Generate diff_report.json upon successful job completion
// Generate diff_report.json upon successful job completion (if feature flag is enabled)
try {
const isDiffEnabled = await systemConfigService.isFeatureEnabled(
const { ChangeDetectionService } =
"feature.change_detection.enabled",
await import("../modules/change-detection/change-detection.service");
true,
const changeDetectionService = new ChangeDetectionService();
);
const diffReport =
if (isDiffEnabled) {
await changeDetectionService.generateAndSaveDiffReport(jobId);
try {
const { ChangeDetectionService } =
await import("../modules/change-detection/change-detection.service");
const changeDetectionService = new ChangeDetectionService();
const diffReport =
await changeDetectionService.generateAndSaveDiffReport(jobId);
console.log(
`
[
Worker
]
Diff
report
generated
for
job
$
{
jobId
}:
$
{
diffReport
.
summary
.
newPagesCount
}
new
,
$
{
diffReport
.
summary
.
modifiedPagesCount
}
modified
,
$
{
diffReport
.
summary
.
deletedPagesCount
}
deleted
,
$
{
diffReport
.
summary
.
unchangedPagesCount
}
unchanged
`,
);
} catch (diffErr: unknown) {
console.error(
`
[
Worker
]
Failed
to
generate
diff
report
for
job
$
{
jobId
}:
`,
getErrorMessage(diffErr),
);
}
} else {
console.log(
console.log(
`
[
Worker
]
Diff
report
generated
for
job
$
{
jobId
}:
$
{
diffReport
.
summary
.
newPagesCount
}
new
,
$
{
diffReport
.
summary
.
modifiedPagesCount
}
modified
,
$
{
diffReport
.
summary
.
deletedPagesCount
}
deleted
,
$
{
diffReport
.
summary
.
unchangedPagesCount
}
unchanged
`,
`
[
Worker
]
Skipped
diff
report
for
job
$
{
jobId
}:
feature
.
change_detection
.
enabled
is
false
`,
);
} catch (diffErr: unknown) {
console.error(
`
[
Worker
]
Failed
to
generate
diff
report
for
job
$
{
jobId
}:
`,
getErrorMessage(diffErr),
);
);
}
}
} catch (error: unknown) {
} catch (error: unknown) {
...
@@ -735,46 +746,52 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -735,46 +746,52 @@ export async function processCrawlJob(job: Job): Promise<void> {
(updatedJob.status === JOB_STATUS.COMPLETED ||
(updatedJob.status === JOB_STATUS.COMPLETED ||
updatedJob.status === JOB_STATUS.FAILED)
updatedJob.status === JOB_STATUS.FAILED)
) {
) {
const { WebhookDeliveryService } =
const isWebhookEnabled = await systemConfigService.isFeatureEnabled(
await import("../modules/webhooks/webhook-delivery.service");
"feature.webhook.deliveries.enabled",
const webhookDeliveryService = new WebhookDeliveryService();
true,
const event =
updatedJob.status === JOB_STATUS.COMPLETED
? WEBHOOK_EVENT.JOB_COMPLETED
: WEBHOOK_EVENT.JOB_FAILED;
void logStep(
jobId,
updatedJob.status === JOB_STATUS.COMPLETED ? "INFO" : "ERROR",
updatedJob.status,
`
Job
finished
with
status
$
{
updatedJob
.
status
}
$
{
updatedJob
.
errorMessage
?
`:
${
updatedJob
.
errorMessage
}
`
:
""
}
`,
);
const diffSummary =
updatedJob &&
typeof updatedJob === "object" &&
"diffSummary" in updatedJob
? ((updatedJob as { diffSummary: unknown }).diffSummary ?? null)
: null;
const payload = {
jobId: updatedJob.id,
status: updatedJob.status,
startUrl: updatedJob.startUrl,
mode: updatedJob.mode,
totalPages: updatedJob.totalPages,
successPages: updatedJob.successPages,
failedPages: updatedJob.failedPages,
errorMessage: updatedJob.errorMessage,
diffSummary,
startedAt: updatedJob.startedAt,
finishedAt: updatedJob.finishedAt,
};
void webhookDeliveryService.dispatch(
updatedJob.id,
updatedJob.userId,
event,
payload,
);
);
if (isWebhookEnabled) {
const { WebhookDeliveryService } =
await import("../modules/webhooks/webhook-delivery.service");
const webhookDeliveryService = new WebhookDeliveryService();
const event =
updatedJob.status === JOB_STATUS.COMPLETED
? WEBHOOK_EVENT.JOB_COMPLETED
: WEBHOOK_EVENT.JOB_FAILED;
void logStep(
jobId,
updatedJob.status === JOB_STATUS.COMPLETED ? "INFO" : "ERROR",
updatedJob.status,
`
Job
finished
with
status
$
{
updatedJob
.
status
}${
updatedJob
.
errorMessage
?
`:
${
updatedJob
.
errorMessage
}
`
:
""
}
`
,
);
const
diffSummary
=
updatedJob
&&
typeof
updatedJob
===
"object"
&&
"diffSummary"
in
updatedJob
?
((
updatedJob
as
{
diffSummary
:
unknown
}).
diffSummary
??
null
)
:
null
;
const
payload
=
{
jobId
:
updatedJob
.
id
,
status
:
updatedJob
.
status
,
startUrl
:
updatedJob
.
startUrl
,
mode
:
updatedJob
.
mode
,
totalPages
:
updatedJob
.
totalPages
,
successPages
:
updatedJob
.
successPages
,
failedPages
:
updatedJob
.
failedPages
,
errorMessage
:
updatedJob
.
errorMessage
,
diffSummary
,
startedAt
:
updatedJob
.
startedAt
,
finishedAt
:
updatedJob
.
finishedAt
,
};
void
webhookDeliveryService
.
dispatch
(
updatedJob
.
id
,
updatedJob
.
userId
,
event
,
payload
,
);
}
}
}
}
catch
(
webhookErr
)
{
}
catch
(
webhookErr
)
{
console
.
error
(
console
.
error
(
...
...
src/routes/index.ts
View file @
0b96ce87
...
@@ -12,6 +12,7 @@ import healthRoute from "../modules/health/health.route";
...
@@ -12,6 +12,7 @@ import healthRoute from "../modules/health/health.route";
import
dashboardRoute
from
"../modules/dashboard/dashboard.route"
;
import
dashboardRoute
from
"../modules/dashboard/dashboard.route"
;
import
roleRoute
from
"../modules/roles/role.route"
;
import
roleRoute
from
"../modules/roles/role.route"
;
import
permissionRoute
from
"../modules/permissions/permission.route"
;
import
permissionRoute
from
"../modules/permissions/permission.route"
;
import
systemConfigRoute
from
"../modules/system-config/system-config.route"
;
const
router
=
Router
();
const
router
=
Router
();
...
@@ -20,6 +21,7 @@ router.use("/auth", authRoute);
...
@@ -20,6 +21,7 @@ router.use("/auth", authRoute);
router
.
use
(
"/users"
,
userRoute
);
router
.
use
(
"/users"
,
userRoute
);
router
.
use
(
"/roles"
,
roleRoute
);
router
.
use
(
"/roles"
,
roleRoute
);
router
.
use
(
"/permissions"
,
permissionRoute
);
router
.
use
(
"/permissions"
,
permissionRoute
);
router
.
use
(
"/system"
,
systemConfigRoute
);
router
.
use
(
"/dashboard"
,
dashboardRoute
);
router
.
use
(
"/dashboard"
,
dashboardRoute
);
router
.
use
(
"/crawl-jobs"
,
crawlJobRoute
);
router
.
use
(
"/crawl-jobs"
,
crawlJobRoute
);
router
.
use
(
"/crawl-schedules"
,
crawlScheduleRoute
);
router
.
use
(
"/crawl-schedules"
,
crawlScheduleRoute
);
...
...
src/server.ts
View file @
0b96ce87
...
@@ -45,7 +45,19 @@ async function bootstrap() {
...
@@ -45,7 +45,19 @@ async function bootstrap() {
initLocalStorage
();
initLocalStorage
();
const
{
systemConfigService
}
=
await
import
(
"./modules/system-config/system-config.service"
);
try
{
await
systemConfigService
.
ensureDefaultConfigs
();
console
.
log
(
"[Server] Default system configs initialized successfully."
);
}
catch
(
err
)
{
console
.
warn
(
"[Server] Failed to initialize default system configs:"
,
err
);
}
if
(
isRedisAvailable
)
{
if
(
isRedisAvailable
)
{
systemConfigService
.
initRedisSubscriber
();
await
import
(
"./queues/webhook.worker"
);
await
import
(
"./queues/webhook.worker"
);
console
.
log
(
"[Server] Webhook worker initialized in background."
);
console
.
log
(
"[Server] Webhook worker initialized in background."
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment