This commit is contained in:
2026-04-06 00:17:17 +07:00
commit 0f7e386e2a
15 changed files with 1283 additions and 0 deletions

237
server.js Normal file
View File

@@ -0,0 +1,237 @@
import path from 'node:path';
import jsonServer from 'json-server';
const server = jsonServer.create();
const router = jsonServer.router(path.join(import.meta.dir, 'db.json'));
// Serve static files từ folder public (images, etc.)
const middlewares = jsonServer.defaults({
logger: true,
static: path.join(import.meta.dir, 'public'),
});
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}`);
});