2026-04-06 00:17:17 +07:00
|
|
|
import path from 'node:path';
|
2026-04-06 00:33:27 +07:00
|
|
|
import { fileURLToPath } from 'node:url';
|
2026-04-06 00:17:17 +07:00
|
|
|
import jsonServer from 'json-server';
|
|
|
|
|
|
2026-04-06 00:33:27 +07:00
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
|
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
|
|
2026-04-06 00:17:17 +07:00
|
|
|
const server = jsonServer.create();
|
2026-04-06 00:33:27 +07:00
|
|
|
const router = jsonServer.router(path.join(__dirname, 'db.json'));
|
2026-04-06 00:17:17 +07:00
|
|
|
|
|
|
|
|
// Serve static files từ folder public (images, etc.)
|
|
|
|
|
const middlewares = jsonServer.defaults({
|
|
|
|
|
logger: true,
|
2026-04-06 00:33:27 +07:00
|
|
|
static: path.join(__dirname, 'public'),
|
2026-04-06 00:17:17 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const port = process.env.PORT || 3100;
|
|
|
|
|
// BASE_URL có thể set từ env khi deploy lên VPS (vd: https://api-mock.example.com)
|
|
|
|
|
const baseUrl = process.env.BASE_URL || `http://localhost:${port}`;
|
|
|
|
|
|
|
|
|
|
server.use(middlewares);
|
|
|
|
|
server.use(jsonServer.bodyParser);
|
|
|
|
|
|
|
|
|
|
server.use((req, _res, next) => {
|
|
|
|
|
req.body = req.body || {};
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const formatCurrencyVnd = (value) => {
|
|
|
|
|
const number = Number(String(value || '').replace(/[^0-9]/g, ''));
|
|
|
|
|
if (Number.isNaN(number) || number <= 0) {
|
|
|
|
|
return '0';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Intl.NumberFormat('en-US').format(number);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildResult = ({ ok = true, responseCode = '00', message = 'Thành công' } = {}) => ({
|
|
|
|
|
ok,
|
|
|
|
|
responseCode,
|
|
|
|
|
message,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const normalizeText = (value) => String(value || '').toLowerCase().trim();
|
|
|
|
|
|
|
|
|
|
// Validation constants theo spec
|
|
|
|
|
const ACCOUNT_NUMBER_LENGTH = 9;
|
|
|
|
|
const MAX_AMOUNT = 1000000000; // 1 tỷ VND
|
|
|
|
|
const ACCOUNT_NUMBER_REGEX = /^[0-9]{9}$/;
|
|
|
|
|
const ACCOUNT_NAME_REGEX = /^[a-zA-ZÀ-ỹ\s]*$/;
|
|
|
|
|
|
|
|
|
|
server.post('/charging-station/execute', (req, res) => {
|
|
|
|
|
const payload = req.body;
|
|
|
|
|
const accountNumber = String(payload.accountNumber || '').trim();
|
|
|
|
|
const accountName = String(payload.accountName || '').trim();
|
|
|
|
|
const amountRaw = String(payload.amount || '').trim();
|
|
|
|
|
const amountNumber = Number(amountRaw.replace(/[^0-9]/g, ''));
|
|
|
|
|
|
|
|
|
|
// V1, V4: Kiểm tra rỗng
|
|
|
|
|
if (!accountNumber || !amountRaw || Number.isNaN(amountNumber) || amountNumber <= 0) {
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
result: buildResult({
|
|
|
|
|
ok: false,
|
|
|
|
|
responseCode: '01',
|
|
|
|
|
message: 'Dữ liệu không hợp lệ',
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// V2, V3: Kiểm tra accountNumber đúng 9 ký tự và chỉ chứa số
|
|
|
|
|
if (!ACCOUNT_NUMBER_REGEX.test(accountNumber)) {
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
result: buildResult({
|
|
|
|
|
ok: false,
|
|
|
|
|
responseCode: '01',
|
|
|
|
|
message: accountNumber.length !== ACCOUNT_NUMBER_LENGTH
|
|
|
|
|
? `Số tài khoản phải có ${ACCOUNT_NUMBER_LENGTH} ký tự`
|
|
|
|
|
: 'Số tài khoản chỉ được chứa số',
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// V6: Kiểm tra accountName không chứa ký tự đặc biệt hoặc số
|
|
|
|
|
if (accountName && !ACCOUNT_NAME_REGEX.test(accountName)) {
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
result: buildResult({
|
|
|
|
|
ok: false,
|
|
|
|
|
responseCode: '01',
|
|
|
|
|
message: 'Tên chỉ được chứa chữ cái và khoảng trắng',
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// V5: Kiểm tra amount không vượt quá 1 tỷ
|
|
|
|
|
if (amountNumber > MAX_AMOUNT) {
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
result: buildResult({
|
|
|
|
|
ok: false,
|
|
|
|
|
responseCode: '03',
|
|
|
|
|
message: `Số tiền tối đa là ${formatCurrencyVnd(MAX_AMOUNT)} VND`,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Simulate: Số tài khoản bắt đầu bằng 999 không tồn tại
|
|
|
|
|
if (accountNumber.startsWith('999')) {
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
result: buildResult({
|
|
|
|
|
ok: false,
|
|
|
|
|
responseCode: '01',
|
|
|
|
|
message: 'Số tài khoản không tồn tại',
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Simulate: Số tiền > 500 triệu và TK bắt đầu bằng 111 → số dư không đủ
|
|
|
|
|
if (amountNumber > 500000000 && accountNumber.startsWith('111')) {
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
result: buildResult({
|
|
|
|
|
ok: false,
|
|
|
|
|
responseCode: '02',
|
|
|
|
|
message: 'Số dư không đủ',
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const transactionSuffix = String(now.getTime()).slice(-10);
|
|
|
|
|
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
result: buildResult(),
|
|
|
|
|
transactionId: `TXN${transactionSuffix}`,
|
|
|
|
|
status: 'SUCCESS',
|
|
|
|
|
message: 'Nạp tiền thành công',
|
|
|
|
|
accountNumber,
|
|
|
|
|
accountName,
|
|
|
|
|
amount: formatCurrencyVnd(amountNumber),
|
|
|
|
|
executedAt: now.toISOString(),
|
|
|
|
|
refNo: `REF${String(now.getTime()).slice(-8)}`,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
server.post('/charging-station/getHomeData', (_req, res) => {
|
|
|
|
|
const db = router.db;
|
|
|
|
|
const homeData = db.get('homeData').value();
|
|
|
|
|
return res.status(200).json(homeData);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
server.post('/charging-station/search', (req, res) => {
|
|
|
|
|
const db = router.db;
|
|
|
|
|
const searchSeed = db.get('searchSeed').value();
|
|
|
|
|
const query = normalizeText(req.body.query);
|
|
|
|
|
|
|
|
|
|
const baseResponse = {
|
|
|
|
|
result: buildResult(),
|
|
|
|
|
recentSearches: searchSeed.recentSearches,
|
|
|
|
|
recommendedFeatures: searchSeed.recommendedFeatures,
|
|
|
|
|
recommendedChips: searchSeed.recommendedChips,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!query) {
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
...baseResponse,
|
|
|
|
|
suggestions: searchSeed.suggestions,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const suggestions = searchSeed.suggestions.filter((item) => {
|
|
|
|
|
const title = normalizeText(item.title);
|
|
|
|
|
const description = normalizeText(item.description);
|
|
|
|
|
return title.includes(query) || description.includes(query);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const recentSearches = [req.body.query, ...searchSeed.recentSearches]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.filter((item, index, array) => array.indexOf(item) === index)
|
|
|
|
|
.slice(0, 10);
|
|
|
|
|
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
...baseResponse,
|
|
|
|
|
suggestions,
|
|
|
|
|
recentSearches,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Helper: build full image URL từ relative path
|
|
|
|
|
const buildImageUrl = (relativePath) => {
|
|
|
|
|
if (!relativePath) return null;
|
|
|
|
|
// Nếu đã là URL đầy đủ thì giữ nguyên
|
|
|
|
|
if (relativePath.startsWith('http://') || relativePath.startsWith('https://')) {
|
|
|
|
|
return relativePath;
|
|
|
|
|
}
|
|
|
|
|
return `${baseUrl}${relativePath.startsWith('/') ? '' : '/'}${relativePath}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
server.post('/charging-station/reward/getList', (req, res) => {
|
|
|
|
|
const db = router.db;
|
|
|
|
|
const rewardSeed = db.get('rewardSeed').value();
|
|
|
|
|
const page = Number(req.body.page || 1);
|
|
|
|
|
const pageSize = Number(req.body.pageSize || 10);
|
|
|
|
|
|
|
|
|
|
const safePage = Number.isNaN(page) || page < 1 ? 1 : page;
|
|
|
|
|
const safePageSize = Number.isNaN(pageSize) || pageSize < 1 ? 10 : pageSize;
|
|
|
|
|
|
|
|
|
|
const start = (safePage - 1) * safePageSize;
|
|
|
|
|
const end = start + safePageSize;
|
|
|
|
|
|
|
|
|
|
// Map vouchers với full image URL
|
|
|
|
|
const vouchers = rewardSeed.vouchers.slice(start, end).map((v) => ({
|
|
|
|
|
...v,
|
|
|
|
|
imageUrl: buildImageUrl(v.imageUrl),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Banners chỉ trả về ở page 1, với full image URL
|
|
|
|
|
const banners =
|
|
|
|
|
safePage === 1
|
|
|
|
|
? rewardSeed.banners.map((b) => ({
|
|
|
|
|
...b,
|
|
|
|
|
imageUrl: buildImageUrl(b.imageUrl),
|
|
|
|
|
}))
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
return res.status(200).json({
|
|
|
|
|
result: buildResult(),
|
|
|
|
|
banners,
|
|
|
|
|
vouchers,
|
|
|
|
|
paging: {
|
|
|
|
|
page: safePage,
|
|
|
|
|
pageSize: safePageSize,
|
|
|
|
|
total: rewardSeed.vouchers.length,
|
|
|
|
|
hasMore: end < rewardSeed.vouchers.length,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
server.use(router);
|
|
|
|
|
|
|
|
|
|
server.listen(port, () => {
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.log(`Charging Station mock API is running at http://localhost:${port}`);
|
|
|
|
|
});
|