First Commit

This commit is contained in:
DATAEXPRESS\4734
2026-01-06 18:24:42 +08:00
commit 65f033adbc
37 changed files with 3010 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 15, // 增加併發處理能力
queueLimit: 0,
multipleStatements: true,
timezone: '+08:00' // 強制時區一致
});
// 啟動測試
pool.getConnection()
.then(conn => {
console.log('✅ MariaDB Connected Successfully.');
conn.release();
})
.catch(err => {
console.error('❌ MariaDB Connection Failed:', err.message);
});
module.exports = pool;
+49
View File
@@ -0,0 +1,49 @@
const nodemailer = require('nodemailer');
const schedule = require('node-schedule');
const db = require('../config/db');
const logger = require('../utils/logger'); // 必須引入!
class MailService {
constructor() {
this.transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 465,
secure: true,
auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS }
});
}
async initAutoMail() {
// 每 30 秒執行一次
schedule.scheduleJob('*/30 * * * * *', async () => {
try {
// 增加一個簡單的連線檢查,避免資料庫掛掉時噴錯
const [rows] = await db.query('SELECT * FROM zen_mail_list WHERE mail_status = 0 LIMIT 10');
if (rows.length > 0) {
logger.info(`AutoMail: Processing ${rows.length} messages.`);
}
for (let row of rows) {
try {
await this.transporter.sendMail({
from: process.env.MAIL_USER,
to: row.recipient,
subject: row.mail_subject,
html: row.content
});
await db.query('UPDATE zen_mail_list SET mail_status = 1, sent_at = NOW() WHERE id = ?', [row.id]);
} catch (mailErr) {
logger.error(`Mail ID ${row.id} failed: ${mailErr.message}`);
await db.query('UPDATE zen_mail_list SET mail_status = 9, error_log = ? WHERE id = ?', [mailErr.message, row.id]);
}
}
} catch (dbErr) {
// 這裡現在可以正確使用 logger 了
logger.error(`AutoMail Job DB Failure: ${dbErr.message}`);
}
});
}
}
module.exports = new MailService();
+516
View File
@@ -0,0 +1,516 @@
/**
* ORM Service for MariaDB
* 功能:處理 amis 請求參數、解析核心 ORM 邏輯、呼叫 MariaDB Stored Procedures
* Author: Senior Engineer Refactor
*/
const db = require('../config/db');
const logger = require('../utils/logger');
class OrmService {
/**
* 執行 Stored Procedure 並回傳 amis 指定格式
* @param {string} spName - SP 名稱
* @param {Array} params - 參數陣列
* @param {number|string} dsNo - 回傳格式代號 (2, 3, 8, 20)
*/
async executeSp(spName, params, dsNo) {
if (spName === "orm_api") {
spName = "orm_api_v2"
}
// 1. 強制確保 params 是陣列,避免 null.map 或 undefined.map 導致當機
const safeParams = Array.isArray(params) ? params : [];
// MariaDB SP 呼叫語法: CALL sp_name(?, ?, ...)
const placeholders = safeParams.map(() => '?').join(',');
const sql = (safeParams.length > 0 || spName != "zen_null")
? `CALL ${spName}(${placeholders})`
: `CALL ${spName}()`; // 無參數時補上括號
// debug & hard code
/*if (spName === "zen_null") {
sql = `CALL ${spName}()`;
}*/
// 配合 amis 特殊執行 js 需透過呼叫 api, 當 dsNo=11 時,就直接 return
if (dsNo === 11 || spName === "zen_null") {
/*return {
status: 0,
code: 0,
msg: 'ok',
data: ''
};*/
return true;
}
// --- 除錯 Log 啟動 ---
const startTime = Date.now();
// 將參數轉為字串方便顯示,若是長字串(JSON)則截斷或完整呈現
const paramsDebug = JSON.stringify(params);
logger.info(`[SQL EXECUTE] Query: ${sql} | Params: ${paramsDebug}`);
// --------------------
try {
const [resultSets] = await db.query(sql, params);
const duration = Date.now() - startTime;
// 取得結果集與狀態 (MariaDB 回傳格式處理)
const status = resultSets[0] && resultSets[0][0] ? resultSets[0][0] : { code: 0, msg: 'Success', recno: 0 };
const dataSet = resultSets[1] || [];
logger.info(`[SQL SUCCESS] SP: ${spName} | Time: ${duration}ms | Status: ${status.code}`);
// 根據 dsNo 格式化回傳內容給前端 amis
const dsNoInt = parseInt(dsNo);
switch (dsNoInt) {
//新增 0,8,9 都是為了配合 amis 的格式需求, 0 是載入程式執行, 配合 genCode, 9 是為了顯示 json
case 0: // CRUD 列表格式
return {
status: status.code,
code: status.code,
msg: status.msg,
data: dataSet[0] ? (typeof dataSet[0].jsoncode === 'string' ? JSON.parse(dataSet[0].jsoncode) : dataSet[0].jsoncode) : {}
};
case 1: // 原始結果集
return resultSets;
case 3: // CRUD 列表格式
return {
status: status.code === 0 ? 0 : status.code,
code: status.code === 0 ? 0 : status.code,
msg: status.msg,
data: {
items: dataSet,
total: status.recno || dataSet.length
}
};
case 5: // Get 使用, 固定回傳 xValue
return {
status: status.code,
code: status.code,
msg: status.msg,
data: {
"xValue": dataSet[0] ? dataSet[0].xValue : null
}
};
case 6: // 動態 options 回傳
//const field6 = returnField || 'options';
const field6 = 'options';
return {
status: status.code,
code: status.code,
msg: status.msg,
data: {
[field6]: dataSet[0] ? (typeof dataSet[0].jsoncode === 'string' ? JSON.parse(dataSet[0].jsoncode) : dataSet[0].jsoncode) : []
}
};
case 7: // Query Report 使用
return {
status: status.code,
code: status.code,
msg: status.msg,
data: {
"count": status.recno,
"items": dataSet[0] ? (typeof dataSet[0].query === 'string' ? JSON.parse(dataSet[0].query) : dataSet[0].query) : []
}
};
case 8: // Options 選項格式 (Select/Checkboxes)
return {
status: 0,
code: 0,
msg: status.msg,
data: { options: dataSet }
};
case 9: // 動態 config 回傳
//const field9 = returnField || 'config';
const field9 = 'config';
return {
status: status.code,
code: status.code,
msg: status.msg,
data: {
[field9]: dataSet[0] ? (typeof dataSet[0].jsoncode === 'string' ? JSON.parse(dataSet[0].jsoncode) : dataSet[0].jsoncode) : {}
}
};
case 20: // 單筆資料格式 (Form Edit)
return {
status: 0,
code: 0,
msg: status.msg,
data: dataSet[0] || {}
};
case 2: // 標準格式
default:
return {
status: 0,
code: 0,
msg: status.msg,
recno: status.recno,
data: dataSet
};
}
} catch (err) {
logger.error(`[SQL ERROR] SP: ${spName} | Error: ${err.message} | Params: ${JSON.stringify(params)}`);
return { status: 500, msg: `DB Error: ${err.message}`, data: null };
}
}
/**
* 解析核心 ORM 邏輯 (最佳化原本 index.js 內的字串切割部分)
* 將 ^ 分隔符號轉換為 MariaDB 易於處理的 JSON 格式
*/
// 修改為接收 req 物件,以便抓取 IP
prepareOrmPayload(req) {
const body = req.body;
let spName = req.params.name;
// --- 情況 A: 固定格式的 orm_api 或 checklogin ---
if (spName === 'orm_api' || spName === 'orm_api_v2') {
logger.info(`[MODE] Fixed Mapping for ${spName}`);
spName = 'orm_api_v2';
// 呼叫 orm_kernel 取得處理後的 orm_data
let ret_orm_data = this.orm_kernel(req);
logger.info(`[ret_orm_data] ${ret_orm_data}`);
// 回傳 5 個參數給 CALL orm_api(?,?,?,?,?)
return [
body.token || '',
body._$_tableName || '',
body._$_pk || '',
body._$_action || '',
ret_orm_data // 這就是第 5 個參數 body.paras
];
}
// For Excel Import(TBD)
if (spName === 'sys_excel_import') {
const importDbname = req.body.db_name || dbname;
const orm_table = req.body.table_name;
const orm_pk = req.body.pk;
const orm_action = 'C';
const orm_data = JSON.stringify(req.body);
spName = 'orm_api_v2';
return [
body.token || '',
orm_table,
orm_pk,
orm_action,
orm_data // 這就是第 5 個參數 body.paras
];
}
// --- 情況 B: 呼叫任意 SP (動態參數模式) ---
// 排除所有系統保留字(以 _$_ 開頭或特定名稱),剩下的全部按順序傳入 SP
// --- 1. 處理第一個固定參數: token ---
const paraKeys = Object.keys(body).filter(key => {
return key !== 'token' && !key.startsWith('_$_');
});
// 字母排序確保順序一致 (para01 -> para02)
paraKeys.sort();
const orderedParas = paraKeys.map(key => body[key]);
// --- 2. 動態偵測是否有 token 參數 ---
let finalParams = [];
const hasToken = Object.prototype.hasOwnProperty.call(body, 'token');
if (hasToken) {
// [模式 A] 有傳入 token[token, para01, para02...]
finalParams = [body.token || '', ...orderedParas];
logger.info(`[PARAM_PREPARE] SP: ${spName} | Detected Token -> [token, ${paraKeys.join(', ')}]`);
} else {
// [模式 B] 沒有傳入 token[para01, para02...]
finalParams = [...orderedParas];
logger.info(`[PARAM_PREPARE] SP: ${spName} | No Token Field -> [${paraKeys.join(', ')}]`);
}
// 3. 統一進行空參數檢查 (防護機制)
if (finalParams.length === 0) {
finalParams = [];
logger.warn(`[PARAM_CHECK] No parameters found for ${spName}. Array is empty.`);
}
// 以下程式是因為 mariadb sp 的參數不支援 default, 為了防錯加入的 20250808
// get_treedata 應傳入2個參數
if (spName === 'get_treedata' || spName === 'get_assignData') {
finalParams = [...orderedParas, "''"];
}
// get_treedata 應傳入3個參數
//if (spName === 'zen_showsource') {
// finalParams = [...orderedParas, "''"];
//}
return finalParams;
}
// null 回傳空白
isnull(str) {
// 檢查是否為 null, undefined 或空字串
if (str === null || str === undefined || str === '') {
return '';
}
return str;
}
//先用 ~ 代替單引號, 在 orm_api 再 replace, 否則無法傳參
quotstr2(str) {
return '~' + str + '~';
}
//// copy from ms-sql version code 2026/01/04
/**
* 核心字串與物件處理邏輯
* 本程式目前是完全照搬, 用 Gemini 優化都會有問題(轉換邏輯不全), 日後在想辦法優化
*/
orm_kernel(req) {
let orm_pageNo = req.body.page || '1';
let orm_perPage = req.body.perPage || '';
let orm_keywords = req.body.keywords || '';
let orm_keyfield = req.body._field_ || '';
let orm_keyid = req.body._id_ || '';
let orm_where = '';
//
let orm_table = req.body._$_tableName;
let orm_pk = req.body._$_pk;
let orm_action = req.body._$_action;
let orm_data = '';
//
let i = 0;
//
let frmQ1 = '';
let frmQ2 = '';
let frmQ3 = '';
let ret_sql= '';
//debug
// console.log(req.body);
// logger.info(`[PARAMS-orm_data] ${req.body}`);
/* orm 改用此種方式, 但 token 該如何處理 ??
新機制: 加入 token, 為了限制查詢的標準功能
*/
//orm_table = req.body._$_tableName;
if (req.body.token) {
orm_table = req.body.token + '^' + req.body._$_tableName;
} else {
orm_table = req.body._$_tableName;
}
orm_pk = req.body._$_pk;
orm_action = req.body._$_action;
//debug
//logger.info(`[debug-_query_filter] ${req.body._$_query_filter}`);
if (req.body._$_query_filter) {
//換頁次
if (req.body.page) {
orm_pageNo = req.body.page;
}
if (req.body.perPage) {
orm_perPage = req.body.perPage;
}
if (req.body.keywords) {
orm_keywords = req.body.keywords;
}
// _id_ 是網址傳遞參數用, 當作 _query_filter 的第 9/10 個參數(where 的一部分)
if (req.body._field_) {
orm_keyfield = req.body._field_;
}
if (req.body._id_) {
orm_keyid = req.body._id_;
}
//debug
logger.info(`[debug-_frmQueryFlds_] ${req.body._$_frmQueryFlds_}`);
logger.info(`[debug-_frmQueryData_] ${req.body._$_frmQueryData_}`);
logger.info(`[debug-_frmQueryType_] ${req.body._$_frmQueryType_}`);
/***********************************
擴充 where 支援 Form Header 的多欄位查詢條件, 稍微複雜的表單, 應該都使用多欄位查詢, 而非單一 Query
前端 post 進來時, 增加 3個 _$_開頭的變數
req.body._$_frmQueryFlds_ exp: bildate^personid^productid
req.body._$_frmQueryData_ exp: 20220101,20221231^A01^P001
req.body._$_frmQueryType_ exp: C^C^C --> C=字串 要前後單引號 I=數字,不加單引號 C 和 I 後面可以加 command, 如 Clike 表示 %%, I>= 表示 >= 該值
Michael 2022/4/23
************************************/
if (req.body._$_frmQueryFlds_) {
frmQ1 = req.body._$_frmQueryFlds_;
frmQ2 = req.body._$_frmQueryData_;
frmQ3 = req.body._$_frmQueryType_;
// debug
// console.log(frmQ2);
if (frmQ1 != '') {
let qryArr1 = frmQ1.split("^");
let qryArr2 = frmQ2.split("^");
let qryArr3 = frmQ3.split("^");
for (i = 0; i < qryArr1.length; i++) {
let v_filter = qryArr3[i].substring(1);
if (qryArr3[i].substr(0, 1) == 'C') {
if (qryArr2[i] != '' && qryArr2[i] != '0') {
//if (qryArr2[i] != '') {
//Date Range (between)
let qryArr4 = qryArr2[i].split(",");
if (qryArr4.length > 1) {
orm_where = orm_where + qryArr1[i] + ' >= ' + this.quotstr2(qryArr4[0]) + ' and ' + qryArr1[i] + ' <= ' + this.quotstr2(qryArr4[1]) + (i == qryArr1.length - 1 ? '' : ' and ');
}
else if (v_filter == 'like') {
orm_where = orm_where + qryArr1[i] + ' like ' + this.quotstr2('%' + qryArr2[i] + '%') + (i == qryArr1.length - 1 ? '' : ' and ');
} else {
orm_where = orm_where + qryArr1[i] + ' = ' + this.quotstr2(qryArr2[i]) + (i == qryArr1.length - 1 ? '' : ' and ');
}
}
} else {
if (qryArr2[i] != '') {
if (v_filter == '') {
orm_where = orm_where + qryArr1[i] + '=' + qryArr2[i] + (i == qryArr1.length - 1 ? '' : ' and ');
} else {
orm_where = orm_where + qryArr1[i] + v_filter + qryArr2[i] + (i == qryArr1.length - 1 ? '' : ' and ');
}
}
}
}
//去掉後5碼的 and
if (orm_where.substr(orm_where.length - 5, 5) == ' and ') {
orm_where = orm_where.substr(0, orm_where.length - 5);
}
}
}
/****************************************************/
orm_data = req.body._$_query_filter;
const myArr = orm_data.split("^");
//特別處理一下 orm_keywords, 1st=* 2st=myArr[7] 3st=非空的orm_keywords
if (orm_keywords == '*') {
orm_keywords = '';
} else if (this.isnull(myArr[7]) != '') {
//防錯(在特殊情況會誤傳, 在 ${ls:query_id} 機制)
if (myArr[7] == '{$ls:token}') {
orm_keywords = '';
} else {
orm_keywords = myArr[7];
}
}
// where keyword
if (orm_where == '' || !orm_where) {
orm_where = '';
}
if (this.isnull(myArr[4]) != '' && orm_where == '') {
orm_where = myArr[4];
}
if (orm_perPage == '' || !orm_perPage) {
orm_perPage = myArr[1];
if (!orm_perPage) { orm_perPage = 10; }
}
// 表單互 call 時, 傳遞參數也會借用此機制, 所以多加一個空值判斷 2023/1/28 Michael
if (this.isnull(myArr[8]) != '' && this.isnull(myArr[9]) != '' && orm_keyfield == '' && orm_keyid == '') {
orm_keyfield = myArr[8];
orm_keyid = myArr[9];
}
// debug to see the content
// logger.info(`[PARAMS-orm_data] ${orm_data}`);
//前端傳入 sample: 1^10^functiontag^*^^^functiontag,tablename,fieldname^abc01^functiontag^abc01
orm_data = orm_pageNo + '^' + orm_perPage + '^' + myArr[2] + '^' + myArr[3] + '^' + orm_where + '^' + this.isnull(myArr[5]) + '^' + this.isnull(myArr[6]) + '^' + orm_keywords + '^' + orm_keyfield + '^' + orm_keyid;
} else //if (spName == 'orm_api') {
{
//專門處理 details 的 Object 問題(amis 的 joinValues Issue)
/*
if (req.body.details) {
for (let i = 0; i < req.body.details.length; i++) {
// 先 hard code productid, 確認無誤後, 再想辦法改為通用 20230828 Michael
if (typeof req.body.details[i].productid === 'object' && req.body.details[i].productid.productid !== null) {
req.body.details[i].productid = req.body.details[i].productid.productid;
}
}
}
orm_api_v2 主要是改為用 parseJSON 分析傳入的 json
主要是 amis 的 details 或是 picker 要多選取回時, json 的內容要在特殊處理
改為 parseJSON 因為會寫入暫存, 相關比較好控制, 但還是要仔細測試過, 目前仍在測試中 20240127
*/
// 先 hard code productid, 確認無誤後, 再想辦法改為通用 20230828 Michael (下列程式碼待測試中)
let obj_id = {};
let obj_id2 = '';
let obj_id3 = {};
//console.log(obj_id);
//quickEdit donot execute
console.log(Object.keys(req.body));
console.log(req.body.hasOwnProperty("config"));
if (Object.keys(req.body)[1] === 'rowsDiff' || Object.keys(req.body)[3] === 'rowsOrigin') {
// break
console.log('quickEdit');
//} else if (Object.keys(req.body)[0] === 'config' || Object.keys(req.body)[6] === 'config') {
} else if (req.body.hasOwnProperty("config") || req.body.hasOwnProperty("options")) {
// break
console.log('config-children or options');
} else if (Object.keys(req.body)[0] === '0' || req.body._$_tableName === 'zen_fields') {
// break
console.log('zen_import_fields orm write error');
} else {
for (let i = 0; i < Object.keys(req.body).length; i++) {
//console.log( Object.keys(req.body)[i]);
if (Object.keys(req.body)[i] == 'details') {
//console.log( Object.keys(req.body.details));
for (let j = 0; j < Object.keys(req.body.details).length; j++) {
// 先 hard code productid, 確認無誤後, 再想辦法改為通用 20230828 Michael
obj_id = req.body.details[Object.keys(req.body.details)[j]];
//console.log(obj_id);
for (let k = 0; k < Object.keys(obj_id).length; k++) {
obj_id3 = obj_id[Object.keys(obj_id)[k]];
if (typeof obj_id3 === 'object') {
obj_id2 = Object.keys(obj_id3)[0];
//console.log(obj_id2);
if (obj_id2 !== null) {
//console.log(obj_id2);
req.body.details[j][obj_id2] = req.body.details[j][obj_id2][obj_id2];
}
}
}
}
} else {
obj_id = req.body[Object.keys(req.body)[i]];
if (typeof obj_id === 'object') {
obj_id2 = Object.keys(obj_id)[0];
//console.log(obj_id2);
if (obj_id2 !== null) {
//req.body[Object.keys(req.body)[i]] = req.body[Object.keys(req.body)[i]][obj_id2];
req.body[obj_id2] = req.body[obj_id2][obj_id2];
}
}
}
}
}
orm_data = JSON.stringify(req.body);
}
//
return orm_data;
}
////
}
module.exports = new OrmService();
+32
View File
@@ -0,0 +1,32 @@
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const { dbname, typeid } = req.params;
const uploadPath = path.join(__dirname, `../../public/upload/${dbname}/${typeid}`);
fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const dateStr = new Date().toISOString().replace(/[:.]/g, '-');
cb(null, `${dateStr}-${file.originalname}`);
}
});
const upload = multer({
storage,
limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 50 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
// 禁止上傳危險副檔名
const forbiddenExtensions = ['.exe', '.sh', '.php', '.js', '.bat'];
const ext = path.extname(file.originalname).toLowerCase();
if (forbiddenExtensions.includes(ext)) {
return cb(new Error('File type not allowed'), false);
}
cb(null, true);
}
});
module.exports = upload;
+44
View File
@@ -0,0 +1,44 @@
const winston = require('winston');
require('winston-daily-rotate-file');
const path = require('path');
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(info => `${info.timestamp} [${info.level.toUpperCase()}]: ${info.message}`)
);
// 設定檔案滾動 (每天產生一個新檔,保留 14 天)
const dailyRotateFileTransport = new winston.transports.DailyRotateFile({
filename: 'logs/application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
format: logFormat
});
const errorFileTransport = new winston.transports.DailyRotateFile({
level: 'error',
filename: 'logs/error-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d',
format: logFormat
});
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
transports: [
dailyRotateFileTransport,
errorFileTransport,
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
logFormat
)
})
]
});
module.exports = logger;