guoziyun 9 місяців тому
батько
коміт
0ededa68a5
55 змінених файлів з 6128 додано та 194 видалено
  1. 1 1
      oms/.gitignore
  2. 13 0
      oms/dist/config/fcm-service-account.json
  3. 55 0
      oms/dist/services/cron-jobs/daily-activity-detector.js
  4. 176 0
      oms/dist/services/cron-jobs/done-rate.js
  5. 72 0
      oms/dist/services/cron-jobs/index.js
  6. 100 0
      oms/dist/services/cron-jobs/message-sender.js
  7. 7 0
      oms/dist/services/cron-jobs/sync/schema-sync-seq.js
  8. 5 0
      oms/dist/services/cron-jobs/sync/sync-conn.js
  9. 5 0
      oms/dist/services/cron-jobs/sync/sync-seq.js
  10. 96 0
      oms/dist/services/cron-jobs/sync/sync-service.js
  11. 217 0
      oms/dist/services/event-api-service.js
  12. 507 0
      oms/dist/services/ingestor-service.js
  13. 224 0
      oms/dist/services/log-service.js
  14. 54 0
      oms/dist/services/message-worker.js
  15. 84 0
      oms/dist/src/app.js
  16. 93 0
      oms/dist/src/controllers/adminController.js
  17. 239 0
      oms/dist/src/controllers/artController.js
  18. 67 0
      oms/dist/src/controllers/doneRateController.js
  19. 176 0
      oms/dist/src/controllers/messageActivityController.js
  20. 166 0
      oms/dist/src/controllers/messageRecordController.js
  21. 107 0
      oms/dist/src/controllers/messageTemplateController.js
  22. 384 0
      oms/dist/src/controllers/userController.js
  23. 31 0
      oms/dist/src/controllers/userTargetingController.js
  24. 44 0
      oms/dist/src/database.js
  25. 15 0
      oms/dist/src/libs/utils.js
  26. 33 0
      oms/dist/src/middleware/authMiddleware.js
  27. 66 0
      oms/dist/src/models/adminModel.js
  28. 138 0
      oms/dist/src/models/artModel.js
  29. 70 0
      oms/dist/src/models/colorRecordModel.js
  30. 79 0
      oms/dist/src/models/doneRateModel.js
  31. 75 0
      oms/dist/src/models/messageActivityModel.js
  32. 98 0
      oms/dist/src/models/messageRecordModel.js
  33. 49 0
      oms/dist/src/models/messageTemplateModel.js
  34. 67 0
      oms/dist/src/models/userModel.js
  35. 66 0
      oms/dist/src/models/userPreferenceModel.js
  36. 65 0
      oms/dist/src/routes/apiRoutes.js
  37. 459 0
      oms/dist/src/scripts/ingestHistoricalData.js
  38. 283 0
      oms/dist/src/scripts/ingestHistoricalDataFromClog.js
  39. 178 0
      oms/dist/src/scripts/migrate-done-rates.js
  40. 133 0
      oms/dist/src/scripts/send-fcm-script.js
  41. 98 0
      oms/dist/src/services/adminService.js
  42. 80 0
      oms/dist/src/services/artService.js
  43. 112 0
      oms/dist/src/services/clickhouseService.js
  44. 146 0
      oms/dist/src/services/doneRateService.js
  45. 92 0
      oms/dist/src/services/fcmService.js
  46. 213 0
      oms/dist/src/services/messageActivityService.js
  47. 80 0
      oms/dist/src/services/messageRecordService.js
  48. 48 0
      oms/dist/src/services/messageTemplateService.js
  49. 50 0
      oms/dist/src/services/rabbitmqService.js
  50. 101 0
      oms/dist/src/services/userService.js
  51. 104 0
      oms/dist/src/services/userTargetingService.js
  52. 151 187
      oms/package-lock.json
  53. 1 0
      oms/package.json
  54. 48 6
      oms/services/event-api-service.ts
  55. 7 0
      oms/services/howto.md

+ 1 - 1
oms/.gitignore

@@ -1,7 +1,7 @@
 # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
 
 # Compiled output
-/dist
+#/dist
 /tmp
 /out-tsc
 /bazel-out

+ 13 - 0
oms/dist/config/fcm-service-account.json

@@ -0,0 +1,13 @@
+{
+    "type": "service_account",
+    "project_id": "art-color-number",
+    "private_key_id": "361c09696e6a2d6438a9244f83d630ecea0776dd",
+    "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCiF7oZbNgc5xKN\n2ogYe46qVRFSbzAwHqJFWAWiCbEyYPEfRdpcLwT9AtTDbyq1pUcRwH5ssriSUJid\n/noQdxBcx0W1BM9ukFjIXHMai1D188tNTQGamx/c8fnqeCLDqrd/w8JgPobql/ma\nCNtuaSx23N7cdQTdf8QHP1DZUFrx+kl4f2L6qYtlHRVfIA2MEZSBQSb57HIJVeGN\nkJK5SF+tvHM8iqfOKO02Xrw7yFdi69DW2WmdPCHLfiSx2o1RhuGaSqZtqci/xXoL\nG8ApdnKXh6WCd+aNCmqwumvbYYdDTLaXGTgw/wdzf//PxMTfnSJrcow59KmNKyD6\nsS7mOeqXAgMBAAECggEATL4oFYaTcWezwKag/dpt6tY4y8spyPaQBH3OuO+Wzg/Y\nyWIkprV6SqHvAxKcaYD1e4GkOamKOnffMhp8R1Rf0lVkevZqkWHVLxOaSYyBSdlh\nvVkCs+TS+qQ0G19Csloe4+ZWnoOsE+DdQ6EC5yzzaNlcyIq8wXQO5xGCoUjqrhp9\neXZyXwIYQrMj0k91EvhX1gTmJ1knq4dkFqqnLJDQI3DcXE2NB3Iwq8oGzNjZ0Fp3\nxuwanPd2ZwzCnHI8hf9YO9VJR6MvAlmVIP7sObaD7qRdXD8Xnd3av0eY8GBxiEnW\nPMTb8h73L0vLA61OIvEy11UEMEzzKs6sVzJfoPLSSQKBgQDR3SpnB01Kf9HMOAgT\nIwLW0EftA9FkYNK7WuKZruSKTxUsaH/kyT0MJVQEOxL/y152KLNQnfs5JUaZJ6lr\nVh7eUL1Y12IUvPEO46r4HiQGwE/C1vQ93iK6oigRA6ft2t/F8OP0pnijDQ2B+LTh\n86gIRWStfFIx3bInX8PPdRfbuQKBgQDFuhDOxocM9vi4CiDIShC6EPyO5NSsKB0c\nRaygC4QqWPsxpeCQ7B5gEP2g7myPKVLMq3fouy+8ishkrrbEGRFfZfh3teJENWJB\nwbMH7CVBxjplzmoJxnO/NkH+TjYKvzi/Tm8+B6tBxl8iJP5Smz1/bVoUX+ADIgcU\nfpwdg4lAzwKBgQClzSbP6RvuTNeykV4HyHRYxIrevVJ0DG7Q6Hf4VQ1oHBytTg4k\n8bxSWTdsdEOJZeHGVld8zKOLPWDuZUBbddnDaGR/yQJLQg7s5X/QsPdjghJB69Nh\nAZvMeYpQDuRgbbi3SJ7ATbknkItocNZvYTIS/sgQrBTAIte6ddVclLT5uQKBgF36\nMrTk3RmHZO2sOqqXsV2OZ0vPbVmp8zQV1Zd4AchS5IlTaunWoBVO3g2YZNaicG1A\n7kwac/TsDZT1CX8o3v31rGPRegqrSNkyJFKWpZqeifELa5Db1vXB5xnkuIDhJCqh\nL/ROltI7Y8oJxSskB9XB9reKXiF/Edhm2PKaKyk3AoGAO3TB+j534IKSCA8fKYdm\nexaZpXS5nQxLD9Co5P1os4Hoc+Bae91cuFT5Nfreg9x/pg5VDeTuWwHmhoB3JorB\nV3UA/E0RKUrXd4qNEv9sAmaY1ZUjF20UaaPK5evvIvQMAJkf8tpnpSRB5iVtJDoa\noOEFeaqUc++PRtQgPMSyMyQ=\n-----END PRIVATE KEY-----\n",
+    "client_email": "firebase-adminsdk-3e134@art-color-number.iam.gserviceaccount.com",
+    "client_id": "105879980189116871384",
+    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+    "token_uri": "https://oauth2.googleapis.com/token",
+    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+    "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3e134%40art-color-number.iam.gserviceaccount.com",
+    "universe_domain": "googleapis.com"
+}

+ 55 - 0
oms/dist/services/cron-jobs/daily-activity-detector.js

@@ -0,0 +1,55 @@
+"use strict";
+// oms/src/services/dailyActivityDetector.ts
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.run = run;
+const date_fns_1 = require("date-fns");
+const messageActivityModel_1 = require("../../src/models/messageActivityModel");
+const rabbitmqService_1 = __importDefault(require("../../src/services/rabbitmqService"));
+const QUEUE_NAME = "message-record-generation-queue";
+/**
+ * 核心检测函数,由外部的 node-cron 任务调用
+ * 连接数据库,查找所有已发布的每日活动,并判断是否需要触发消息生成
+ */
+async function run() {
+    console.log("--- Starting daily message activity check ---");
+    try {
+        // 2. 查找所有符合条件的活动:已发布且设置为每日推送
+        const activities = await messageActivityModel_1.MessageActivity.find({
+            status: 1, // 1 表示已发布 (published)
+            everyday: true,
+        });
+        if (activities.length === 0) {
+            console.log("No daily published activities found.");
+        }
+        // 3. 遍历活动,检查是否需要推送
+        for (const activity of activities) {
+            console.log(`Checking activity: ${activity.name} (ID: ${activity._id})`);
+            const lastPubDate = activity.lastPubDate;
+            const needsNewPush = !lastPubDate || !(0, date_fns_1.isToday)(lastPubDate);
+            if (needsNewPush) {
+                console.log(`-> Activity requires a new push. Last publish date: ${lastPubDate ? lastPubDate.toISOString() : "None"}.`);
+                // 向rabbitmq投递一条消息, 以便异步生成消息推送记录
+                await rabbitmqService_1.default.publishActivityMessage(QUEUE_NAME, { activityId: activity._id });
+            }
+            else {
+                console.log("-> Activity has already run today. Skipping.");
+            }
+        }
+        console.log("--- Daily message activity check finished ---");
+    }
+    catch (error) {
+        if (error instanceof Error) {
+            console.error("An error occurred during the daily check:", error.message);
+        }
+        else {
+            console.error("An unknown error occurred during the daily check.");
+        }
+    }
+    finally {
+    }
+}
+// 启动时立即运行一次,以便在部署后立即处理积压的消息
+run();

+ 176 - 0
oms/dist/services/cron-jobs/done-rate.js

@@ -0,0 +1,176 @@
+"use strict";
+// oms/services/cron-jobs/done-rate.ts
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+const dayjs_1 = __importDefault(require("dayjs"));
+const doneRateService_1 = __importDefault(require("../../src/services/doneRateService")); // 导入 DoneRateService
+const artService_1 = __importDefault(require("../../src/services/artService")); // 👈 导入 ArtService
+const app_1 = require("../../src/app"); // 导入 ClickhouseService 实例
+const mongoose_1 = __importDefault(require("mongoose")); // 导入 mongoose 和 Connection 用于处理远程连接
+const artModel_1 = __importDefault(require("../../src/models/artModel")); // 👈 导入 Art 模型和 IArt 接口
+// ClickHouse 表名
+const CLICKHOUSE_EVENTS_TABLE = "events_raw"; // 确保与 ClickHouseService 中的表名一致
+// 远程数据库连接 URL
+const REMOTE_MONGO_URI = "mongodb://coloring:coloring123.@hk.jccytech.cn:7881/?authSource=admin";
+/**
+ * 每日统计昨天的作品完成率。
+ * 统计逻辑从 ClickHouse 中提取数据,得到每个作品的完成情况,并更新到 doneRateModel。
+ * 随后,根据这些日统计数据,累加更新本地和远程的 Art 表的总统计字段。
+ * @returns Promise<string> - 返回统计结果的摘要信息。
+ */
+async function run() {
+    console.log("[DoneRate Cron] Starting daily done-rate calculation for yesterday...");
+    // 获取昨天和今天的日期
+    const yesterday = (0, dayjs_1.default)().subtract(1, "day");
+    const yesterdayYYYYMMDD = yesterday.format("YYYYMMDD");
+    const yesterdayStart = yesterday.startOf("day").toDate();
+    const yesterdayEnd = yesterday.endOf("day").toDate();
+    console.log(`[DoneRate Cron] Processing data for date: ${yesterdayYYYYMMDD}`);
+    let remoteConn = null;
+    let updatedRemoteArtworksCount = 0;
+    try {
+        // --- 1. 从 ClickHouse 中提取数据 ---
+        // 查询昨天每个作品的独立开始用户数
+        const startCountsQuery = `
+      SELECT
+          res,
+          count(DISTINCT uid) AS unique_starts
+      FROM ${CLICKHOUSE_EVENTS_TABLE}
+      WHERE event = 'color_start'
+        AND time >= toDateTime('${(0, dayjs_1.default)(yesterdayStart).toISOString()}')
+        AND time < toDateTime('${(0, dayjs_1.default)(yesterdayEnd).toISOString()}')
+      GROUP BY res
+      HAVING res IS NOT NULL
+      FORMAT JSONEachRow
+    `;
+        const startResults = await app_1.clickhouseService.queryEvents(startCountsQuery);
+        const artworkStartCounts = new Map();
+        startResults.forEach((row) => {
+            if (row.res && mongoose_1.default.Types.ObjectId.isValid(row.res)) {
+                artworkStartCounts.set(row.res, row.unique_starts);
+            }
+            else {
+                console.warn(`[DoneRate Cron] Invalid artwork ID found in start_counts result: ${row.res}`);
+            }
+        });
+        console.log(`[DoneRate Cron] Retrieved ${startResults.length} unique start counts from ClickHouse.`);
+        // 查询昨天每个作品的独立完成用户数
+        const doneCountsQuery = `
+      SELECT
+          res,
+          count(DISTINCT uid) AS unique_dones
+      FROM ${CLICKHOUSE_EVENTS_TABLE}
+      WHERE event = 'color_done'
+        AND time >= toDateTime('${(0, dayjs_1.default)(yesterdayStart).toISOString()}')
+        AND time < toDateTime('${(0, dayjs_1.default)(yesterdayEnd).toISOString()}')
+      GROUP BY res
+      HAVING res IS NOT NULL
+      FORMAT JSONEachRow
+    `;
+        const doneResults = await app_1.clickhouseService.queryEvents(doneCountsQuery);
+        const artworkDoneCounts = new Map();
+        doneResults.forEach((row) => {
+            if (row.res && mongoose_1.default.Types.ObjectId.isValid(row.res)) {
+                artworkDoneCounts.set(row.res, row.unique_dones);
+            }
+            else {
+                console.warn(`[DoneRate Cron] Invalid artwork ID found in done_counts result: ${row.res}`);
+            }
+        });
+        console.log(`[DoneRate Cron] Retrieved ${doneResults.length} unique done counts from ClickHouse.`);
+        // --- 2. 合并数据并更新 DoneRate 模型 ---
+        let updatedRecordsCount = 0; // for DoneRate
+        let createdRecordsCount = 0; // for DoneRate
+        // 遍历所有有开始事件的作品ID
+        for (const [resIdStr, startCount] of artworkStartCounts.entries()) {
+            const doneCount = artworkDoneCounts.get(resIdStr) || 0;
+            const resObjectId = new mongoose_1.default.Types.ObjectId(resIdStr);
+            // 使用 DoneRateService 来创建或更新记录
+            const doneRateDoc = await doneRateService_1.default.createOrUpdateDoneRate(yesterdayYYYYMMDD, resObjectId, startCount, doneCount);
+            if (doneRateDoc.createdAt.getTime() === doneRateDoc.updatedAt.getTime()) {
+                createdRecordsCount++;
+            }
+            else {
+                updatedRecordsCount++;
+            }
+            artworkDoneCounts.delete(resIdStr); // 已经处理过的作品ID从 doneCounts 中移除
+        }
+        // 处理只有完成事件但没有开始事件的作品 (通常不应发生,但以防万一)
+        for (const [resIdStr, doneCount] of artworkDoneCounts.entries()) {
+            const startCount = 0; // 没有开始事件,所以开始次数为0
+            const resObjectId = new mongoose_1.default.Types.ObjectId(resIdStr);
+            const doneRateDoc = await doneRateService_1.default.createOrUpdateDoneRate(yesterdayYYYYMMDD, resObjectId, startCount, doneCount);
+            if (doneRateDoc.createdAt.getTime() === doneRateDoc.updatedAt.getTime()) {
+                createdRecordsCount++;
+            }
+            else {
+                updatedRecordsCount++;
+            }
+        }
+        const totalProcessedArtworks = createdRecordsCount + updatedRecordsCount;
+        console.log(`[DoneRate Cron] DoneRate model update completed. Total artworks processed: ${totalProcessedArtworks}. Created: ${createdRecordsCount}, Updated: ${updatedRecordsCount}.`);
+        // --- 3. 获取昨天的所有 DoneRate 记录,并更新本地和远程的 Art 表 ---
+        // 建立远程数据库连接和模型
+        remoteConn = await mongoose_1.default.createConnection(REMOTE_MONGO_URI);
+        const RemoteArt = remoteConn.model("Art", artModel_1.default.schema);
+        console.log(`[DoneRate Cron] Connected to remote database.`);
+        let updatedLocalArtworksCount = 0; // for Art model
+        const yesterdayDoneRates = await doneRateService_1.default.getDoneRatesByDate(yesterdayYYYYMMDD);
+        console.log(`[DoneRate Cron] Found ${yesterdayDoneRates.length} DoneRate records for yesterday to update Art table.`);
+        for (const doneRateDoc of yesterdayDoneRates) {
+            try {
+                const artworkId = doneRateDoc.res; // 获取作品 ObjectId
+                const currentArt = await artService_1.default.getArtById(artworkId.toString());
+                if (currentArt) {
+                    // 累加总开始数和总完成数
+                    const newTotalStartCount = (currentArt.totalStartCount || 0) + doneRateDoc.startCount;
+                    const newTotalDoneCount = (currentArt.totalDoneCount || 0) + doneRateDoc.doneCount;
+                    // 重新计算总完成率
+                    const newCompletionRate = newTotalStartCount > 0 ? (newTotalDoneCount / newTotalStartCount) * 100 : 0;
+                    // **【新增】** 更新本地 Art 文档
+                    // 不更新本地art了
+                    // await artService.updateArt(artworkId.toString(), {
+                    //   totalStartCount: newTotalStartCount,
+                    //   totalDoneCount: newTotalDoneCount,
+                    //   completionRate: newCompletionRate,
+                    // });
+                    // updatedLocalArtworksCount++;
+                    // **【新增】** 同步更新远程 Art 文档
+                    const remoteArtDoc = await RemoteArt.findById(artworkId);
+                    if (remoteArtDoc) {
+                        remoteArtDoc.totalStartCount = newTotalStartCount;
+                        remoteArtDoc.totalDoneCount = newTotalDoneCount;
+                        remoteArtDoc.completionRate = newCompletionRate;
+                        await remoteArtDoc.save();
+                        updatedRemoteArtworksCount++;
+                    }
+                    else {
+                        console.warn(`[DoneRate Cron] Remote Art document with ID ${artworkId} not found. Skipping remote update.`);
+                    }
+                }
+                else {
+                    console.warn(`[DoneRate Cron] Local Art document with ID ${artworkId} not found for DoneRate record (date: ${doneRateDoc.date}). Skipping Art update.`);
+                }
+            }
+            catch (artUpdateError) {
+                console.error(`[DoneRate Cron] Error updating Art document for artwork ID ${doneRateDoc.res}:`, artUpdateError);
+            }
+        }
+        const summary = `[DoneRate Cron] Daily done-rate calculation for ${yesterdayYYYYMMDD} completed. Total DoneRate processed: ${totalProcessedArtworks}. Created DoneRate: ${createdRecordsCount}, Updated DoneRate: ${updatedRecordsCount}. Updated Local Art records: ${updatedLocalArtworksCount}. Updated Remote Art records: ${updatedRemoteArtworksCount}.`;
+        console.log(summary);
+        return summary;
+    }
+    catch (error) {
+        console.error(`[DoneRate Cron] Error during done-rate calculation for ${yesterdayYYYYMMDD}:`, error);
+        throw new Error("Failed to calculate daily done-rates."); // 抛出错误以通知 cron 调度器
+    }
+    finally {
+        // 确保在任何情况下都关闭远程连接
+        if (remoteConn) {
+            await remoteConn.close();
+            console.log("[DoneRate Cron] Disconnected from remote database.");
+        }
+    }
+}
+module.exports = { run };

+ 72 - 0
oms/dist/services/cron-jobs/index.js

@@ -0,0 +1,72 @@
+"use strict";
+// oms/services/cron-jobs/index.ts
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.startCronJobs = startCronJobs;
+const node_cron_1 = __importDefault(require("node-cron")); // Import node-cron library
+const database_1 = require("../../src/database");
+// Define the settings array for cron jobs
+// Each element: [name: string, schedule: string, jobModule: CronJobModule]
+const settings = [
+    // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
+    ["sync", "*/10 * * * *", require("./sync/sync-service")], // 每10分钟跑一次同步
+    ["done-rate", "10 0 * * *", require("./done-rate")], // 每天凌晨0点10分, 统计作品完成率
+    ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector")], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
+    ["message-sender", "*/5 * * * *", require("./message-sender")], // 每5分钟运行一次
+];
+/**
+ * Starts all scheduled cron jobs.
+ * Includes database connection and task scheduling.
+ * @returns Promise<void>
+ */
+async function startCronJobs() {
+    console.log("[Cron Jobs] Initializing all scheduled tasks...");
+    // 在启动所有定时任务之前,首先建立数据库连接
+    await (0, database_1.connectToDatabase)();
+    // Iterate through settings and schedule each job
+    settings.forEach((setting) => {
+        const [name, schedule, job] = setting;
+        if (!job || typeof job.run !== "function") {
+            // Check if job module and run function exist
+            console.error(`[Cron Jobs] Job [${name}] is missing a run() function or is not a valid module. Skipping.`);
+            return;
+        }
+        console.log(`[Cron Jobs] Installing job [${name}] to run at '${schedule}'`);
+        node_cron_1.default.schedule(schedule, async () => {
+            const startTime = new Date().toLocaleString();
+            console.log(`[Cron Jobs] Running job [${name}]@'${schedule}' started @ ${startTime}`);
+            try {
+                const result = await job.run(); // Execute the job's run function
+                console.log(`[Cron Jobs] Job [${name}] completed successfully @ ${new Date().toLocaleString()}. Result:`, result);
+            }
+            catch (error) {
+                console.error(`[Cron Jobs] Job [${name}] failed @ ${new Date().toLocaleString()}. Error:`, error);
+            }
+        });
+    });
+    console.log("[Cron Jobs] All cron jobs started.");
+}
+// If this file is run directly (e.g., using `node dist/services/cron-jobs/index.js`)
+if (require.main === module) {
+    startCronJobs().catch((error) => {
+        console.error("[Cron Jobs] Failed to start cron jobs:", error);
+        process.exit(1);
+    });
+    // Handle graceful shutdown
+    process.on("SIGINT", async () => {
+        console.log("[Cron Jobs] Shutting down...");
+        node_cron_1.default.getTasks().forEach((task) => task.stop()); // Stop all scheduled tasks
+        // await disconnectFromDatabase(); // 在退出前断开数据库连接
+        console.log("[Cron Jobs] All cron tasks stopped.");
+        process.exit(0);
+    });
+    process.on("SIGTERM", async () => {
+        console.log("[Cron Jobs] Shutting down...");
+        node_cron_1.default.getTasks().forEach((task) => task.stop());
+        // await disconnectFromDatabase(); // 在退出前断开数据库连接
+        console.log("[Cron Jobs] All cron tasks stopped.");
+        process.exit(0);
+    });
+}

+ 100 - 0
oms/dist/services/cron-jobs/message-sender.js

@@ -0,0 +1,100 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.run = void 0;
+const mongoose_1 = __importDefault(require("mongoose"));
+const messageRecordModel_1 = require("../../src/models/messageRecordModel");
+const userModel_1 = require("../../src/models/userModel");
+const fcmService_1 = require("../../src/services/fcmService");
+const utils_1 = require("../../src/libs/utils");
+const fcmService = fcmService_1.FCMService.getInstance();
+/**
+ * 消息发送任务:
+ * 1. 查询所有状态为0(未发送)且计划发送时间已到的消息记录。
+ * 2. 遍历这些消息,为每个消息找到对应的用户和设备令牌。
+ * 3. 调用 FCMService 发送消息。
+ * 4. 根据发送结果更新 messageRecord 的状态和相关字段。
+ *
+ * 注意:数据库连接由外部的调度器(例如 index.ts)统一管理。
+ */
+const run = async () => {
+    console.log("Starting message sender cron job...");
+    try {
+        // 检查连接状态,确保在执行查询前数据库已连接
+        if (mongoose_1.default.connection.readyState !== 1) {
+            console.error("[Message Sender] Database connection is not ready. Skipping execution.");
+            return;
+        }
+        // 查找状态为0且计划发送时间小于或等于当前时间的消息记录
+        const recordsToSend = await messageRecordModel_1.MessageRecord.find({
+            status: 0,
+            plannedSendAt: { $lte: new Date() },
+        }).limit(100);
+        console.log(`Found ${recordsToSend.length} messages to send.`);
+        for (const record of recordsToSend) {
+            try {
+                const user = await userModel_1.User.findOne({ uid: record.uid });
+                if (!user || !user.fmToken) {
+                    console.warn(`User ${record.uid} not found or no FCM token, updating record status to failed.`);
+                    await messageRecordModel_1.MessageRecord.findByIdAndUpdate(record._id, {
+                        status: -1,
+                        actualSendAt: new Date(),
+                        errno: "User not found or no FCM token",
+                    });
+                    continue;
+                }
+                const messageData = (0, utils_1.filterEmptyProps)({
+                    msgid: record._id.toString(),
+                    title: record.title,
+                    content: record.content,
+                    image: record.image || "",
+                    bigger: record.bigger?.toString() || "false",
+                    action: record.action || "",
+                    param: record.param || "",
+                    extend: record.extend || "",
+                });
+                const result = await fcmService.sendMessage(user.fmToken, messageData);
+                if (result instanceof Error) {
+                    // 检查是否是由于无效的FCM Token导致的失败
+                    const isTokenInvalid = result.message.includes("messaging/invalid-argument") || result.message.includes("messaging/registration-token-not-registered") || result.message.includes("messaging/unregistered");
+                    let updateOps = {
+                        status: -1,
+                        actualSendAt: new Date(),
+                        errno: result.message,
+                    };
+                    if (isTokenInvalid) {
+                        // 如果是Token无效,则从用户文档中移除fcmToken
+                        console.warn(`[Message Sender] Invalid FCM Token for user ${record.uid}. Clearing token.`);
+                        await userModel_1.User.updateOne({ uid: record.uid }, { $unset: { fmToken: 1 } });
+                        updateOps.errno = `Invalid FCM Token. Token has been cleared. Original error: ${result.message}`;
+                    }
+                    await messageRecordModel_1.MessageRecord.findByIdAndUpdate(record._id, updateOps);
+                }
+                else {
+                    // 成功发送,更新记录
+                    await messageRecordModel_1.MessageRecord.findByIdAndUpdate(record._id, {
+                        status: 1,
+                        actualSendAt: new Date(),
+                        fcmReceipt: result,
+                        errno: null,
+                    });
+                }
+            }
+            catch (error) {
+                console.error(`Failed to process message record ${record._id}:`, error);
+                await messageRecordModel_1.MessageRecord.findByIdAndUpdate(record._id, {
+                    status: -1,
+                    actualSendAt: new Date(),
+                    errno: "Internal server error while processing message",
+                });
+            }
+        }
+        console.log("Message sender cron job finished.");
+    }
+    catch (err) {
+        console.error("Error in message sender cron job:", err);
+    }
+};
+exports.run = run;

+ 7 - 0
oms/dist/services/cron-jobs/sync/schema-sync-seq.js

@@ -0,0 +1,7 @@
+"use strict";
+const { Schema } = require("mongoose");
+// slave 记录同步event sequence
+let syncSeqSchema = new Schema({
+    seq: { type: Number, required: true, desc: '同步seq,只有一条记录' }
+});
+module.exports = syncSeqSchema;

+ 5 - 0
oms/dist/services/cron-jobs/sync/sync-conn.js

@@ -0,0 +1,5 @@
+"use strict";
+const mongoose = require("mongoose");
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+const syncConn = mongoose.createConnection(MONGO_URI);
+module.exports = syncConn;

+ 5 - 0
oms/dist/services/cron-jobs/sync/sync-seq.js

@@ -0,0 +1,5 @@
+"use strict";
+const syncConn = require('./sync-conn');
+const syncSeqSchema = require('./schema-sync-seq');
+const SyncSeq = syncConn.model('SyncSeq', syncSeqSchema);
+module.exports = SyncSeq;

+ 96 - 0
oms/dist/services/cron-jobs/sync/sync-service.js

@@ -0,0 +1,96 @@
+"use strict";
+const MongoClient = require("mongoose").mongo.MongoClient;
+const SyncSeq = require("./sync-seq");
+const { format } = require("date-fns");
+const remotedb = "coloring_ol"; // 远端数据库
+const localdb = "omsdb"; // 本地的数据库
+/**
+ * sync from remote
+ */
+async function run() {
+    const INIT_SEQ = process.env.INIT_SEQ || 7368116;
+    const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+    const REMOTE_SYNC_MONGO_URI = process.env.REMOTE_SYNC_MONGO_URI || "mongodb://coloring:coloring123.@hk.jccytech.cn:7881?authSource=admin";
+    const localClient = await new MongoClient(MONGO_URI).connect();
+    const remoteClient = await new MongoClient(REMOTE_SYNC_MONGO_URI).connect();
+    let remoteDB = remoteClient.db(remotedb);
+    let localDB = localClient.db(localdb);
+    console.log("Connect to remote mongodb " + REMOTE_SYNC_MONGO_URI + " success!");
+    // 读取远程syncevent表
+    const synceventTB = remoteDB.collection("syncevents");
+    // get current local slave sync seq (use mongoose)
+    let seq = INIT_SEQ;
+    let seqDoc = await SyncSeq.findOne();
+    if (seqDoc)
+        seq = seqDoc.seq;
+    else
+        seqDoc = new SyncSeq({ seq });
+    let count, localtb, remotetb, localdoc, remotedoc;
+    setTimeout(async function cycleRun() {
+        try {
+            let eventDocs = (await synceventTB
+                .find({ _id: { $gt: seq } })
+                .limit(200)
+                .toArray()) || [];
+            count = 0;
+            for (let i = 0; i < eventDocs.length; i++) {
+                let eventDoc = eventDocs[i];
+                // 只同步特定的arts表
+                if (eventDoc.tb == "arts") {
+                    console.log(eventDoc);
+                    if (!eventDoc.db)
+                        eventDoc.db = "coloring_ol";
+                    // 对应的表
+                    remotetb = remoteDB.collection(eventDoc.tb);
+                    localtb = localDB.collection(eventDoc.tb);
+                    if (remotetb && localtb) {
+                        if (eventDoc.op == "remove") {
+                            await localtb.deleteOne({ _id: eventDoc.rid });
+                            console.log("sync remove : " + eventDoc.tb + " " + eventDoc.rid);
+                        }
+                        else if (eventDoc.op == "save") {
+                            remotedoc = await remotetb.findOne({ _id: eventDoc.rid });
+                            localdoc = await localtb.findOne({ _id: eventDoc.rid });
+                            if (!remotedoc) {
+                                console.log("remote doc not found, may be deleted, skip: " + eventDoc.tb + " " + eventDoc.rid);
+                            }
+                            else if (!localdoc) {
+                                console.log("sync add :" + eventDoc.tb + " " + eventDoc.rid);
+                                try {
+                                    await localtb.insertOne(remotedoc);
+                                }
+                                catch (e) {
+                                    console.error(e);
+                                }
+                            }
+                            else {
+                                console.log("sync update :" + eventDoc.tb + " " + eventDoc.rid);
+                                remotedoc.totalStartCount = localdoc.totalStartCount;
+                                remotedoc.totalDoneCount = localdoc.totalDoneCount;
+                                remotedoc.completionRate = localdoc.completionRate;
+                                await localtb.replaceOne({ _id: eventDoc.rid }, remotedoc);
+                            }
+                        }
+                    }
+                }
+                seq = eventDoc._id;
+                seqDoc.seq = seq;
+                count++;
+            }
+            await seqDoc.save();
+            console.log(`${format(new Date(), "yyyy-MM-dd HH:mm")} sync event length: ${eventDocs.length}, precessed: ${count}`);
+        }
+        catch (err) {
+            console.error(err);
+        }
+        if (count > 0)
+            setTimeout(cycleRun, 0);
+        // else setTimeout(run, delay);
+    }, 0);
+}
+module.exports = {
+    run,
+};
+if (require.main == module) {
+    run().catch(console.error);
+}

+ 217 - 0
oms/dist/services/event-api-service.js

@@ -0,0 +1,217 @@
+"use strict";
+// oms/src/event/app.ts
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+// Load environment variables (e.g., RABBITMQ_URL, RABBITMQ_EXCHANGE, RABBITMQ_LOG_QUEUE, RABBITMQ_OMS_QUEUE, PORT)
+const dotenv = __importStar(require("dotenv"));
+dotenv.config();
+const express_1 = __importDefault(require("express"));
+const amqplib_1 = __importDefault(require("amqplib")); // Import amqplib for RabbitMQ interaction
+const morgan_1 = __importDefault(require("morgan")); // 👈 新增:导入 morgan 库
+const mongoose_1 = __importDefault(require("mongoose")); // 👈 导入 mongoose 用于生成 ObjectId
+const app = (0, express_1.default)();
+const port = process.env.EVENT_PORT ? parseInt(process.env.EVENT_PORT, 10) : 3001; // Event API 监听端口,默认为 3001
+// RabbitMQ Configuration
+const RABBITMQ_URL = process.env.RABBITMQ_URL || "amqp://coloring:coloring123.@localhost:5672";
+const RABBITMQ_EXCHANGE_NAME = process.env.RABBITMQ_EXCHANGE || "event_exchange"; // <-- 从环境变量读取交换机名称
+const RABBITMQ_LOG_QUEUE = process.env.RABBITMQ_LOG_QUEUE || "log-event-queue"; // <-- 从环境变量读取日志队列名称
+const RABBITMQ_OMS_QUEUE = process.env.RABBITMQ_OMS_QUEUE || "oms_event_queue"; // <-- 从环境变量读取摄取器队列名称
+let amqpConnection;
+let amqpChannel;
+// --- Initialize RabbitMQ Connection and Channel ---
+async function connectRabbitMQ() {
+    try {
+        amqpConnection = await amqplib_1.default.connect(RABBITMQ_URL);
+        amqpConnection.on("error", (err) => {
+            console.error("[RabbitMQ] Connection error:", err);
+            // Implement reconnection logic here for production
+            setTimeout(connectRabbitMQ, 5000); // Attempt to reconnect after 5 seconds
+        });
+        amqpConnection.on("close", () => {
+            console.error("[RabbitMQ] Connection closed. Reconnecting...");
+            setTimeout(connectRabbitMQ, 5000); // Attempt to reconnect after 5 seconds
+        });
+        amqpChannel = await amqpConnection.createChannel();
+        console.log("[RabbitMQ] Channel created.");
+        // Assert the exchange for persistent broadcast
+        // 'fanout' exchange will broadcast messages to all bound queues.
+        await amqpChannel.assertExchange(RABBITMQ_EXCHANGE_NAME, "fanout", {
+            durable: true,
+        });
+        console.log(`[RabbitMQ] Exchange '${RABBITMQ_EXCHANGE_NAME}' asserted.`);
+        // Assert and bind the Log Queue
+        await amqpChannel.assertQueue(RABBITMQ_LOG_QUEUE, { durable: true });
+        console.log(`[RabbitMQ] Queue '${RABBITMQ_LOG_QUEUE}' asserted.`);
+        await amqpChannel.bindQueue(RABBITMQ_LOG_QUEUE, RABBITMQ_EXCHANGE_NAME, ""); // Routing key is ignored for fanout
+        console.log(`[RabbitMQ] Queue '${RABBITMQ_LOG_QUEUE}' bound to exchange '${RABBITMQ_EXCHANGE_NAME}'.`);
+        // Assert and bind the Ingestor Queue
+        await amqpChannel.assertQueue(RABBITMQ_OMS_QUEUE, { durable: true });
+        console.log(`[RabbitMQ] Queue '${RABBITMQ_OMS_QUEUE}' asserted.`);
+        await amqpChannel.bindQueue(RABBITMQ_OMS_QUEUE, RABBITMQ_EXCHANGE_NAME, ""); // Routing key is ignored for fanout
+        console.log(`[RabbitMQ] Queue '${RABBITMQ_OMS_QUEUE}' bound to exchange '${RABBITMQ_EXCHANGE_NAME}'.`);
+        console.log(`Event Producer connected to RabbitMQ: ${RABBITMQ_URL}`);
+    }
+    catch (error) {
+        console.error("[RabbitMQ] Failed to connect or setup RabbitMQ:", error);
+        // Retry connection
+        setTimeout(connectRabbitMQ, 5000);
+    }
+}
+// --- Middleware ---
+// 服务部署在反向代理(如 Nginx)后面,需设置此项以正确获取客户端 IP
+app.set("trust proxy", true);
+// 👈 新增:使用 morgan 中间件来记录所有请求
+// :remote-addr 和 :req[host] 会通过 app.set("trust proxy", true) 正常工作
+app.use((0, morgan_1.default)('[:date[clf]] :remote-addr :req[host] :status :response-time ms :res[content-length] ":method :url HTTP/:http-version" ":referrer" ":user-agent"'));
+app.use(express_1.default.json()); // To parse JSON request bodies
+// --- API Endpoint: /napi/event/v2 ---
+app.post("/napi/event/v2", async (req, res) => {
+    if (!amqpChannel) {
+        console.error("[Event API] RabbitMQ channel not available.");
+        return res.status(500).json({ message: "Server is not ready to process events." });
+    }
+    const eventData = req.body;
+    if (!eventData || Object.keys(eventData).length === 0) {
+        return res.status(400).json({ message: "Event data is required." });
+    }
+    // 添加必要字段
+    eventData.ip = req.ip;
+    eventData.t = new Date();
+    eventData.cc = req.header("x-country-code") || "nil";
+    eventData._id = new mongoose_1.default.Types.ObjectId();
+    try {
+        const message = JSON.stringify(eventData);
+        // Publish the message to the exchange for broadcast
+        // persistent: true ensures the message survives RabbitMQ restarts
+        // '' as routing key for fanout exchange means it goes to all bound queues
+        const published = amqpChannel.publish(RABBITMQ_EXCHANGE_NAME, "", // Routing key (ignored by fanout exchange)
+        Buffer.from(message), { persistent: true } // Mark message as persistent
+        );
+        if (published) {
+            // console.log('[Event API] Event published to RabbitMQ:', eventData); // Commented for high volume
+            res.status(200).json({ msg: "ok" });
+        }
+        else {
+            // This case indicates the RabbitMQ buffer is full.
+            // In a real-world scenario, you might want to implement backpressure or retry.
+            console.warn("[Event API] Failed to publish event to RabbitMQ (channel full). Event:", eventData);
+            res.status(503).json({ message: "Service temporarily unavailable, please retry." });
+        }
+    }
+    catch (error) {
+        console.error("[Event API] Error publishing event to RabbitMQ:", error);
+        res.status(500).json({ message: "Failed to process event." });
+    }
+});
+// --- API Endpoint: /napi/event/ ---
+app.post("/napi/event/", async (req, res) => {
+    if (!amqpChannel) {
+        console.error("[Event API] RabbitMQ channel not available.");
+        return res.status(500).json({ message: "Server is not ready to process events." });
+    }
+    const eventData = req.body;
+    if (!eventData || Object.keys(eventData).length === 0) {
+        return res.status(400).json({ message: "Event data is required." });
+    }
+    // 添加必要字段
+    eventData.ip = req.ip;
+    eventData.t = new Date();
+    eventData.cc = req.header("x-country-code") || "nil";
+    eventData._id = new mongoose_1.default.Types.ObjectId();
+    try {
+        const message = JSON.stringify(eventData);
+        // Publish the message to the exchange for broadcast
+        // persistent: true ensures the message survives RabbitMQ restarts
+        // '' as routing key for fanout exchange means it goes to all bound queues
+        const published = amqpChannel.publish(RABBITMQ_EXCHANGE_NAME, "", // Routing key (ignored by fanout exchange)
+        Buffer.from(message), { persistent: true } // Mark message as persistent
+        );
+        if (published) {
+            // console.log('[Event API] Event published to RabbitMQ:', eventData); // Commented for high volume
+            res.status(200).json({ msg: "ok" });
+        }
+        else {
+            // This case indicates the RabbitMQ buffer is full.
+            // In a real-world scenario, you might want to implement backpressure or retry.
+            console.warn("[Event API] Failed to publish event to RabbitMQ (channel full). Event:", eventData);
+            res.status(503).json({ message: "Service temporarily unavailable, please retry." });
+        }
+    }
+    catch (error) {
+        console.error("[Event API] Error publishing event to RabbitMQ:", error);
+        res.status(500).json({ message: "Failed to process event." });
+    }
+});
+// --- Start the Express Server ---
+async function startServer() {
+    await connectRabbitMQ(); // Connect to RabbitMQ before starting the server
+    app
+        .listen(port, () => {
+        console.log(`Event Producer API listening on port ${port}`);
+        console.log(`Event endpoint: /napi/event/v2`);
+    })
+        .on("error", (err) => {
+        if (err.code === "EADDRINUSE") {
+            console.error(`Port ${port} is already in use.`);
+            process.exit(1);
+        }
+        else {
+            console.error(`Failed to start Event Producer API:`, err);
+            process.exit(1);
+        }
+    });
+}
+// Start the Event API server
+startServer().catch(console.error);
+// Handle graceful shutdown
+process.on("SIGINT", async () => {
+    console.log("[Event API] Shutting down...");
+    if (amqpChannel)
+        await amqpChannel.close();
+    if (amqpConnection)
+        await amqpConnection.close();
+    process.exit(0);
+});
+process.on("SIGTERM", async () => {
+    console.log("[Event API] Shutting down...");
+    if (amqpChannel)
+        await amqpChannel.close();
+    if (amqpConnection)
+        await amqpConnection.close();
+    process.exit(0);
+});

+ 507 - 0
oms/dist/services/ingestor-service.js

@@ -0,0 +1,507 @@
+"use strict";
+// oms/src/ingestor-service/app.ts
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+// Load environment variables (e.g., RABBITMQ_URL, RABBITMQ_OMS_QUEUE, MONGO_URI, CLICKHOUSE_*)
+const dotenv = __importStar(require("dotenv"));
+dotenv.config();
+const amqplib_1 = __importDefault(require("amqplib"));
+const mongoose_1 = __importDefault(require("mongoose"));
+const dayjs_1 = __importDefault(require("dayjs")); // For date manipulation
+const duration_1 = __importDefault(require("dayjs/plugin/duration")); // dayjs plugin for duration
+const isSameOrBefore_1 = __importDefault(require("dayjs/plugin/isSameOrBefore")); // Day.js plugin for isSameOrBefore
+// Import OMS models and services
+const userModel_1 = require("../src/models/userModel"); // Assuming userModel.ts exports User
+const userPreferenceModel_1 = __importDefault(require("../src/models/userPreferenceModel")); // Assuming userPreferenceModel.ts exports UserPreference
+const messageRecordModel_1 = require("../src/models/messageRecordModel"); // 新增导入 MessageRecord
+const clickhouseService_1 = require("../src/services/clickhouseService"); // Assuming clickhouseService.ts exports ClickhouseService and IEventLog
+dayjs_1.default.extend(duration_1.default);
+dayjs_1.default.extend(isSameOrBefore_1.default);
+// --- Environment Variables ---
+const RABBITMQ_URL = process.env.RABBITMQ_URL || "amqp://coloring:coloring123.@localhost:5672";
+const RABBITMQ_OMS_QUEUE = process.env.RABBITMQ_OMS_QUEUE || "oms_event_queue"; // 摄取器订阅的队列
+const OMS_MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin"; // MongoDB URI
+const CLICKHOUSE_HOST = process.env.CLICKHOUSE_HOST || "http://localhost:8123";
+const CLICKHOUSE_DATABASE = process.env.CLICKHOUSE_DATABASE || "omsdb";
+const CLICKHOUSE_USER = process.env.CLICKHOUSE_USER || "ckuser";
+const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD || "ckpassword";
+const CLICKHOUSE_EVENTS_TABLE = "events"; // ClickHouse 日志表的名称 (与 event/app.ts 和 ClickHouse table schema 保持一致)
+// --- Batching Configuration ---
+const CLICKHOUSE_BATCH_SIZE = 5000; // ClickHouse 批量插入大小
+const MONGO_USER_BATCH_SIZE = 1000; // MongoDB User 批量写入大小
+const MONGO_PREF_BATCH_SIZE = 500; // MongoDB UserPreference 批量写入大小
+const FLUSH_INTERVAL_MS = 5000; // 定时刷新缓冲区间隔 (毫秒)
+// --- Internal State ---
+let amqpConnection;
+let amqpChannel;
+let mongoConnection;
+let clickhouseService;
+const clickhouseEventsBuffer = [];
+const mongoUserWriteOperations = [];
+const mongoUserPrefWriteOperations = [];
+// List of event types to process (reused from ingestHistoricalData.ts)
+const ALLOWED_EVENT_TYPES = [
+    "visit",
+    "show_deeplink_dialog",
+    "share",
+    "save",
+    "revenue",
+    "rate",
+    "favorite",
+    "color_tip",
+    "color_start",
+    "color_done",
+    "color_data",
+    "ad_color_tip",
+    "ad_color_float",
+    "message_receive",
+    "message_open", // 新增消息相关事件
+];
+// Define an array of valid IUser keys for copying from event log to User model
+const USER_FIELDS_TO_UPDATE = [
+    "network",
+    "campaign",
+    "adgroup",
+    "creative",
+    "prod",
+    "libraryName", // maps to library_name
+    "cc",
+    "lang",
+    "manufacturer",
+    "deviceModel", // maps to model
+    "deviceInfo", // maps to device
+    "hardware",
+    "deviceMem",
+    "apiLevel", // maps to android_api
+    "versionName", // maps to version_name or library_version
+    "versionCode", // maps to version_code
+    "fmToken", // map to token
+    "project", // Add project field for updating, map to project_id
+];
+// --- Initialize Services ---
+async function initializeServices() {
+    try {
+        // Connect to RabbitMQ
+        amqpConnection = await amqplib_1.default.connect(RABBITMQ_URL);
+        amqpConnection.on("error", (err) => {
+            console.error("[RabbitMQ] Connection error:", err);
+            // Reconnection logic will be handled by PM2 restarting the service, or a dedicated handler
+        });
+        amqpConnection.on("close", () => {
+            console.error("[RabbitMQ] Connection closed. Restarting service...");
+            // In a production setup, consider graceful shutdown and PM2 restarting
+            process.exit(1); // Exit to allow PM2 to restart
+        });
+        amqpChannel = await amqpConnection.createChannel();
+        console.log("[RabbitMQ] Channel created for Ingestor Service.");
+        // Assert the ingestor queue
+        await amqpChannel.assertQueue(RABBITMQ_OMS_QUEUE, { durable: true });
+        console.log(`[RabbitMQ] Ingestor Service queue '${RABBITMQ_OMS_QUEUE}' asserted.`);
+        // Connect to OMS MongoDB (using Mongoose)
+        mongoConnection = await mongoose_1.default.connect(OMS_MONGO_URI);
+        console.log(`Connected to OMS MongoDB: ${OMS_MONGO_URI}`);
+        // Initialize ClickHouse service with credentials
+        clickhouseService = new clickhouseService_1.ClickhouseService(CLICKHOUSE_HOST, CLICKHOUSE_DATABASE, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD);
+        // Ensure ClickHouse table exists
+        await clickhouseService.ensureTable(CLICKHOUSE_EVENTS_TABLE);
+        console.log(`ClickHouse Service initialized for ${CLICKHOUSE_DATABASE} at ${CLICKHOUSE_HOST}`);
+    }
+    catch (error) {
+        console.error("[Ingestor Service] Failed to initialize services:", error);
+        process.exit(1); // Exit to allow PM2 to restart on critical startup failure
+    }
+}
+// --- Helper function to flush ClickHouse buffer ---
+async function flushClickHouseBuffer() {
+    if (clickhouseEventsBuffer.length === 0)
+        return;
+    const eventsToFlush = [...clickhouseEventsBuffer]; // Take a snapshot
+    clickhouseEventsBuffer.length = 0; // Clear buffer immediately
+    try {
+        await clickhouseService.insertEvent(CLICKHOUSE_EVENTS_TABLE, eventsToFlush);
+        console.log(`[ClickHouse] Flushed ${eventsToFlush.length} events to ClickHouse.`);
+    }
+    catch (error) {
+        console.error(`[ClickHouse] Error flushing ClickHouse buffer:`, error);
+        // On error, decide whether to re-queue (if transient) or log and drop (if data issue)
+        // For now, we log and drop to avoid blocking the ingestor.
+    }
+}
+// --- Helper function to flush MongoDB User buffer ---
+async function flushMongoUserBuffer() {
+    if (mongoUserWriteOperations.length === 0)
+        return;
+    const operationsToFlush = [...mongoUserWriteOperations]; // Take a snapshot
+    mongoUserWriteOperations.length = 0; // Clear buffer immediately
+    try {
+        const bulkResult = await userModel_1.User.bulkWrite(operationsToFlush);
+        console.log(`[MongoDB-User] Flushed ${operationsToFlush.length} operations. Upserted/Modified: ${bulkResult.upsertedCount + bulkResult.modifiedCount}`);
+    }
+    catch (bulkError) {
+        console.error(`[MongoDB-User] Error in MongoDB bulkWrite:`, bulkError);
+    }
+}
+// --- Helper function to flush MongoDB UserPreference buffer ---
+async function flushMongoUserPrefBuffer() {
+    if (mongoUserPrefWriteOperations.length === 0)
+        return;
+    const operationsToFlush = [...mongoUserPrefWriteOperations]; // Take a snapshot
+    mongoUserPrefWriteOperations.length = 0; // Clear buffer immediately
+    try {
+        const bulkResult = await userPreferenceModel_1.default.bulkWrite(operationsToFlush);
+        console.log(`[MongoDB-UserPref] Flushed ${operationsToFlush.length} operations. Upserted/Modified: ${bulkResult.upsertedCount + bulkResult.modifiedCount}`);
+    }
+    catch (bulkError) {
+        console.error(`[MongoDB-UserPref] Error in MongoDB UserPreference bulkWrite:`, bulkError);
+    }
+}
+/**
+ * Handles updating a MessageRecord based on a message event (receive or open).
+ * @param eventData The parsed event object from RabbitMQ.
+ * @param eventType The type of the event ('message_receive' or 'message_open').
+ */
+async function handleMessageEvent(eventData, eventType) {
+    const msgId = eventData.msgid;
+    const fcmId = eventData.fcmid;
+    const eventTime = (0, dayjs_1.default)(eventData.t).toDate();
+    const inForeground = eventData.inforeground === "true" || eventData.inforeground === true;
+    try {
+        let updateFields;
+        let query;
+        // Determine update fields based on event type
+        if (eventType === "message_receive") {
+            updateFields = {
+                status: 2, // 2: delivered
+                deliveredAt: eventTime,
+                inforeground: inForeground,
+            };
+        }
+        else if (eventType === "message_open") {
+            updateFields = {
+                status: 3, // 3: opened
+                openedAt: eventTime,
+                // inforeground will be updated by the receive event, so no need to set here
+            };
+        }
+        else {
+            console.warn(`[Ingestor Service] Unhandled message event type: ${eventType}`);
+            return;
+        }
+        // Determine query filter based on available IDs
+        if (msgId && mongoose_1.default.Types.ObjectId.isValid(msgId)) {
+            query = { _id: msgId };
+        }
+        else if (fcmId) {
+            query = { fcmReceipt: fcmId };
+        }
+        else {
+            console.warn(`[Ingestor Service] Missing msgid or fcmid for event type: ${eventType}. Event: ${JSON.stringify(eventData)}`);
+            return;
+        }
+        // Perform the update
+        const result = await messageRecordModel_1.MessageRecord.updateOne(query, { $set: updateFields });
+        // Log the result of the update operation
+        if (result.matchedCount > 0) {
+            console.log(`[MongoDB-MessageRecord] Updated record for ${eventType} event. Matched: ${result.matchedCount}, Modified: ${result.modifiedCount}`);
+        }
+        else {
+            console.warn(`[MongoDB-MessageRecord] No matching record found for ${eventType} event. Query: ${JSON.stringify(query)}`);
+        }
+    }
+    catch (error) {
+        console.error(`[MongoDB-MessageRecord] Error updating record for ${eventType} event:`, error);
+    }
+}
+// --- Process a single event message ---
+async function processMessage(msg) {
+    if (!amqpChannel) {
+        console.error("[Ingestor Service] RabbitMQ channel not available for processing message.");
+        return;
+    }
+    let eventData;
+    try {
+        eventData = JSON.parse(msg.content.toString());
+        console.log("[Ingestor Service] Received raw event:", eventData); // Log for debugging, but be cautious with high volume
+    }
+    catch (parseError) {
+        console.error(`[Ingestor Service] Error parsing message content: ${msg.content.toString()}. Error: ${parseError}`);
+        amqpChannel.reject(msg, false); // Reject malformed message, do not re-queue
+        return;
+    }
+    // 增加对 eventLog.duration 的校验
+    if (eventData.duration > 100000 || eventData.duration < 0) {
+        console.warn(`[Ingestor Service] Skipping event with invalid duration: ${eventData.duration}. Event: ${JSON.stringify(eventData)}`);
+        amqpChannel.ack(msg); // Acknowledge and drop invalid messages
+        return;
+    }
+    // Filter by project_id
+    const projectId = eventData.project_id || eventData.project; // project_id for android/ios events, project for oms_app events
+    if (projectId !== 1 && projectId !== 6) {
+        // Assuming project_id 1 and 6 are relevant
+        // console.log(`[Ingestor Service] Skipping event with unsupported project_id: ${projectId}`);
+        amqpChannel.ack(msg); // Acknowledge and drop unsupported events
+        return;
+    }
+    // Determine event type field name based on project_id and event source
+    // Assuming 'type' for Android-like events, 'name' for iOS-like events,
+    const eventType = eventData.type || eventData.name;
+    // --- 1. Handle Message-Specific Events First ---
+    if (["message_receive", "message_open"].includes(eventType)) {
+        await handleMessageEvent(eventData, eventType);
+        amqpChannel.ack(msg); // Acknowledge message after processing message events
+        return;
+    }
+    // Filter by allowed event types
+    if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
+        // console.log(`[Ingestor Service] Skipping event with unsupported event_type: ${eventType}`);
+        amqpChannel.ack(msg); // Acknowledge and drop unsupported events
+        return;
+    }
+    // Determine UID field name based on project_id
+    const uid = eventData.uid || eventData.user_id; // uid for Android, user_id for iOS
+    if (!uid) {
+        console.warn(`[Ingestor Service] Skipping event with missing UID: ${JSON.stringify(eventData)}`);
+        amqpChannel.reject(msg, false); // Reject if UID is missing, do not re-queue
+        return;
+    }
+    try {
+        // Calculate lastActiveAtDateObj once for consistency
+        let lastActiveAtDateObj;
+        if (eventData.t) {
+            lastActiveAtDateObj = (0, dayjs_1.default)(eventData.t).toDate();
+        }
+        else if (eventData.create_at) {
+            lastActiveAtDateObj = (0, dayjs_1.default)(eventData.create_at).toDate();
+        }
+        else {
+            lastActiveAtDateObj = new Date();
+        }
+        // --- 2. Prepare Event Data for ClickHouse Batch ---
+        const clickhouseEvent = {
+            log_id: eventData._id ? eventData._id.toString() : new mongoose_1.default.Types.ObjectId().toHexString(), // Use existing _id or generate new
+            uid: uid,
+            project: projectId,
+            os: eventData.library_name || null,
+            version: projectId === 1 ? eventData.version_name : eventData.library_version || null,
+            event: eventType,
+            time: lastActiveAtDateObj, // Directly pass Date object, ClickhouseService handles formatting
+            res: projectId === 1 ? eventData.res : eventData.sku_id || null,
+            from: projectId === 1 ? eventData.from : eventData.tab_source || null,
+            position: projectId === 1 ? eventData.position : eventData.click_position || null,
+            duration: eventData.duration || null,
+            ad_type: eventData.ad_type || null,
+            ad_src: eventData.ad_src || null,
+            revenue: projectId === 1 ? eventData.rev : eventData.ad_revenue || null,
+            cc: eventData.cc || null,
+            raw_json_data: JSON.stringify(eventData),
+        };
+        clickhouseEventsBuffer.push(clickhouseEvent);
+        // --- 3. Prepare User Data for MongoDB Batch Update ---
+        // userSetData will contain fields to be updated using $set for both new and existing documents.
+        // 'project' is now excluded here as it will be handled by $setOnInsert only.
+        const userSetData = { lastActiveAt: lastActiveAtDateObj };
+        // SetOnInsert fields will only apply when a new document is created
+        const setOnInsertFields = {
+            uid: uid,
+            createdAt: new Date(),
+            project: projectId, // `project` is a required field, must be set on insert
+        };
+        // Derive firstLoginAt from 'days' field if available
+        let derivedFirstLoginAt;
+        if (eventData.days !== undefined && eventData.days !== null) {
+            derivedFirstLoginAt = (0, dayjs_1.default)(lastActiveAtDateObj).subtract(eventData.days, "day").toDate();
+        }
+        // Set firstLoginAt for $setOnInsert (for new documents)
+        // This value will only be used if the document is actually inserted (upsert: true creates a new doc)
+        setOnInsertFields.firstLoginAt = derivedFirstLoginAt || lastActiveAtDateObj;
+        // Copy relevant fields from event to userSetData (for $set)
+        for (const field of USER_FIELDS_TO_UPDATE) {
+            if (field === "uid" || field === "project")
+                continue;
+            let sourceFieldName;
+            if (field === "libraryName")
+                sourceFieldName = "library_name";
+            else if (field === "deviceModel")
+                sourceFieldName = "model";
+            else if (field === "deviceInfo")
+                sourceFieldName = "device";
+            else if (field === "apiLevel")
+                sourceFieldName = "android_api";
+            else if (field === "versionName")
+                sourceFieldName = projectId === 1 ? "version_name" : "library_version";
+            else if (field === "versionCode")
+                sourceFieldName = "version_code";
+            else if (field === "deviceMem")
+                sourceFieldName = "deviceMem";
+            else if (field === "fmToken")
+                sourceFieldName = "token";
+            else
+                sourceFieldName = field; // Default to same name
+            if (sourceFieldName && eventData[sourceFieldName] !== undefined && eventData[sourceFieldName] !== null) {
+                if (field === "deviceMem" && typeof eventData[sourceFieldName] === "number") {
+                    userSetData.deviceMem = eventData[sourceFieldName];
+                }
+                else {
+                    // Type assertion needed as Partial<IUser> doesn't guarantee all keys are assignable at runtime
+                    userSetData[field] = eventData[sourceFieldName];
+                }
+            }
+        }
+        // Initialize update object with $set and $setOnInsert
+        const updateOperation = {
+            $set: userSetData,
+            $setOnInsert: setOnInsertFields,
+        };
+        // 👈 关键修改:移除 $min 操作符
+        // `firstLoginAt` 将只在 `$setOnInsert` 时被设置,
+        // 如果文档已存在,它将不会被更新,这符合您的需求。
+        mongoUserWriteOperations.push({
+            updateOne: {
+                filter: { uid: uid },
+                update: updateOperation, // 使用构建好的 updateOperation
+                upsert: true,
+            },
+        });
+        // --- 4. Conditionally Update UserPreference in MongoDB ---
+        // If 'color_start' event and it has tags (or can derive from prod)
+        const tagsToProcess = [];
+        if (eventType === "color_start") {
+            if (Array.isArray(eventData.tags) && eventData.tags.length > 0) {
+                tagsToProcess.push(...eventData.tags);
+            }
+        }
+        if (tagsToProcess.length > 0) {
+            for (const tag of tagsToProcess) {
+                mongoUserPrefWriteOperations.push({
+                    updateOne: {
+                        filter: { uid: uid, tag: tag },
+                        update: { $inc: { count: 1 }, $set: { uid: uid, tag: tag } },
+                        upsert: true,
+                        setDefaultsOnInsert: true, // Ensures defaults are applied on upsert (like count: 0)
+                    },
+                });
+            }
+        }
+        // --- Batch Flushing Logic ---
+        if (clickhouseEventsBuffer.length >= CLICKHOUSE_BATCH_SIZE) {
+            await flushClickHouseBuffer();
+        }
+        if (mongoUserWriteOperations.length >= MONGO_USER_BATCH_SIZE) {
+            await flushMongoUserBuffer();
+        }
+        if (mongoUserPrefWriteOperations.length >= MONGO_PREF_BATCH_SIZE) {
+            await flushMongoUserPrefBuffer();
+        }
+        amqpChannel.ack(msg); // Acknowledge message after successful buffering/processing
+    }
+    catch (error) {
+        console.error(`[Ingestor Service] Error processing event from UID ${uid}:`, error, "Event:", eventData);
+        // Reject message without re-queuing to prevent infinite loops on consistent processing failures
+        amqpChannel.reject(msg, false);
+    }
+}
+// --- Main Ingestor Service Start Function ---
+async function startIngestorService() {
+    await initializeServices();
+    if (!amqpChannel) {
+        console.error("[Ingestor Service] RabbitMQ channel is not available after initialization. Exiting.");
+        process.exit(1);
+    }
+    // Set consumer prefetch count
+    amqpChannel.prefetch(100);
+    console.log(`[Ingestor Service] Waiting for messages in queue: ${RABBITMQ_OMS_QUEUE}`);
+    amqpChannel.consume(RABBITMQ_OMS_QUEUE, async (msg) => {
+        if (msg === null)
+            return; // Channel closed or other null message
+        await processMessage(msg);
+    }, { noAck: false } // Manual acknowledgment
+    );
+    // Set up periodic flushing for any remaining buffered events
+    setInterval(async () => {
+        // console.log("[Ingestor Service] Flushing any remaining buffers...");
+        await flushClickHouseBuffer();
+        await flushMongoUserBuffer();
+        await flushMongoUserPrefBuffer();
+    }, FLUSH_INTERVAL_MS);
+}
+// --- Graceful Shutdown ---
+async function gracefulShutdown() {
+    console.log("[Ingestor Service] Shutting down...");
+    // Flush any remaining buffers before closing connections
+    await flushClickHouseBuffer();
+    await flushMongoUserBuffer();
+    await flushMongoUserPrefBuffer();
+    if (amqpChannel) {
+        try {
+            await amqpChannel.close();
+            console.log("[Ingestor Service] RabbitMQ channel closed.");
+        }
+        catch (e) {
+            console.error("[Ingestor Service] Error closing RabbitMQ channel:", e);
+        }
+    }
+    if (amqpConnection) {
+        try {
+            await amqpConnection.close();
+            console.log("[Ingestor Service] RabbitMQ connection closed.");
+        }
+        catch (e) {
+            console.error("[Ingestor Service] Error closing RabbitMQ connection:", e);
+        }
+    }
+    if (mongoose_1.default.connection.readyState === 1) {
+        // Check if connected before trying to disconnect
+        try {
+            await mongoose_1.default.disconnect();
+            console.log("[Ingestor Service] MongoDB connection closed.");
+        }
+        catch (e) {
+            console.error("[Ingestor Service] Error closing MongoDB connection:", e);
+        }
+    }
+    process.exit(0);
+}
+process.on("SIGINT", gracefulShutdown);
+process.on("SIGTERM", gracefulShutdown);
+// --- Start the service if run directly ---
+if (require.main === module) {
+    console.log("Ingestor Service started in standalone mode.");
+    startIngestorService().catch(console.error);
+}
+// Export the start function for PM2
+exports.default = startIngestorService;

+ 224 - 0
oms/dist/services/log-service.js

@@ -0,0 +1,224 @@
+"use strict";
+// oms/src/log-service/app.ts
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+// Load environment variables (e.g., RABBITMQ_URL, RABBITMQ_LOG_QUEUE, LOG_DIR)
+const dotenv = __importStar(require("dotenv"));
+dotenv.config();
+const amqplib_1 = __importDefault(require("amqplib")); // 明确导入 Connection, Channel, Message 类型
+const rfs = __importStar(require("rotating-file-stream")); // 👈 关键修复:使用 `import * as rfs`
+const moment_1 = __importDefault(require("moment")); // 导入 moment (用于日志文件名生成)
+const path = __importStar(require("path")); // 导入 path 模块
+// --- Environment Variables ---
+const RABBITMQ_URL = process.env.RABBITMQ_URL || "amqp://coloring:coloring123.@localhost:5672";
+const RABBITMQ_LOG_QUEUE = process.env.RABBITMQ_LOG_QUEUE || "log-event-queue"; // 日志服务订阅的队列
+const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, "..", "..", "logs", "coloring"); // 日志文件存储路径
+let amqpConnection; // 使用 undefined 初始化,因为连接是异步的
+let amqpChannel; // 使用 undefined 初始化,因为频道是异步的
+// --- Log Rotation Setup ---
+// 确保日志目录存在
+try {
+    if (!require("fs").existsSync(LOG_DIR)) {
+        require("fs").mkdirSync(LOG_DIR, { recursive: true });
+        console.log(`[Log Service] Created log directory: ${LOG_DIR}`);
+    }
+}
+catch (error) {
+    console.error(`[Log Service] Failed to create log directory ${LOG_DIR}:`, error);
+    process.exit(1);
+}
+// 日志文件名生成器
+const generator = (time, index) => {
+    if (!time)
+        return "coloring.log"; // 初始文件名
+    const suffix = (0, moment_1.default)(time).format("YYYYMMDD");
+    return `coloring-${suffix}.log.gz`; // 每日轮换并压缩
+};
+// 创建文件写入流
+const logStream = rfs.createStream(generator, {
+    interval: "1d", // 每日轮换
+    compress: "gzip", // 使用 gzip 压缩
+    path: LOG_DIR, // 日志文件路径
+    intervalBoundary: true, // 确保在指定时间边界轮换
+    // size: '10M',            // 也可以配置按大小轮换,例如每 10MB
+    // maxFiles: 10,           // 最多保留 10 个文件
+    // maxSize: '100M',        // 最大总大小
+});
+// 监听日志流事件 (可选,用于调试和监控)
+logStream.on("external", () => console.log("[Log Stream] external"));
+logStream.on("history", () => console.log("[Log Stream] history"));
+logStream.on("open", (f) => console.log(`[Log Stream] Opened: ${f}`));
+logStream.on("removed", (f) => console.log(`[Log Stream] Removed: ${f}`));
+logStream.on("rotation", () => console.log("[Log Stream] rotation"));
+logStream.on("rotated", (f) => console.log(`[Log Stream] Rotated to: ${f}`));
+logStream.on("warning", (e) => console.warn("[Log Stream] warning:", e));
+logStream.on("error", (e) => console.error("[Log Stream] error:", e)); // 捕获写入错误
+// --- Utility Functions ---
+function delay(ms) {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+}
+/**
+ * 将事件数据写入日志文件。
+ * @param data - 要写入的日志数据字符串。
+ * @returns Promise<void> - 写入成功或失败。
+ */
+function pushToFile(data) {
+    return new Promise((resolve, reject) => {
+        // 使用 stream.write 的回调函数来判断写入是否成功
+        logStream.write(`${data}\n`, (error) => {
+            if (error) {
+                return reject(error);
+            }
+            resolve();
+        });
+    });
+}
+/**
+ * 带重试机制地将消息推送到文件。
+ * @param data - 要写入的日志数据字符串。
+ * @param ms - 每次重试的延迟时间(毫秒)。
+ * @param retries - 最大重试次数。
+ * @returns Promise<void> - 如果写入成功或所有重试都失败。
+ */
+async function retryPushToFile(data, ms = 1000, retries = 5) {
+    try {
+        await pushToFile(data);
+        return; // 成功写入
+    }
+    catch (err) {
+        console.error(`[Log Service] Failed to write to log (retries left: ${retries}):`, err);
+        if (retries > 0) {
+            await delay(ms);
+            return retryPushToFile(data, ms, retries - 1); // 递归重试
+        }
+        else {
+            throw new Error(`Failed to write to log after ${retries} retries.`); // 抛出最终错误
+        }
+    }
+}
+// --- Main Log Service Start Function ---
+async function startLogService() {
+    try {
+        amqpConnection = await amqplib_1.default.connect(RABBITMQ_URL);
+        amqpConnection.on("error", (err) => {
+            console.error("[RabbitMQ] Connection error:", err);
+            if (amqpChannel)
+                amqpChannel.close();
+            if (amqpConnection)
+                amqpConnection.close();
+            amqpConnection = undefined;
+            amqpChannel = undefined;
+            setTimeout(startLogService, 5000); // 尝试重新连接
+        });
+        amqpConnection.on("close", () => {
+            console.error("[RabbitMQ] Connection closed. Reconnecting...");
+            amqpConnection = undefined;
+            amqpChannel = undefined;
+            setTimeout(startLogService, 5000); // 尝试重新连接
+        });
+        amqpChannel = await amqpConnection.createChannel();
+        console.log("[RabbitMQ] Channel created for Log Service.");
+        // 确保队列存在且是持久化的
+        await amqpChannel.assertQueue(RABBITMQ_LOG_QUEUE, { durable: true });
+        console.log(`[RabbitMQ] Log Service queue '${RABBITMQ_LOG_QUEUE}' asserted.`);
+        // 设置消费者预取数量,平衡吞吐量和资源使用
+        amqpChannel.prefetch(100);
+        console.log(`Log Service connected to RabbitMQ and waiting for messages in queue: ${RABBITMQ_LOG_QUEUE}`);
+        amqpChannel.consume(RABBITMQ_LOG_QUEUE, async (msg) => {
+            if (msg === null)
+                return; // Channel closed or other null message
+            try {
+                const eventDataString = msg.content.toString();
+                console.log("[Log Service] Received event:", eventDataString); // 生产环境可以减少日志量
+                await retryPushToFile(eventDataString, 3000, 5); // 写入文件,带重试
+                if (amqpChannel) {
+                    amqpChannel.ack(msg); // 成功写入文件后确认消息
+                }
+            }
+            catch (err) {
+                console.error("[Log Service] Failed to process or write message to log after retries. Rejecting message:", err);
+                if (amqpChannel) {
+                    // 拒绝消息,不重新入队,防止反复失败阻塞队列
+                    // 或者根据您的策略,可以设置为 true 重新入队
+                    amqpChannel.reject(msg, false);
+                }
+            }
+        }, { noAck: false } // 必须手动确认
+        );
+    }
+    catch (error) {
+        console.error("[Log Service] Failed to connect to RabbitMQ or start consuming:", error);
+        // 在 PM2 管理下,这里不直接 exit(1),让 PM2 尝试重启整个服务
+        // setTimeout(startLogService, 5000); // 如果是单进程运行,可以尝试重连
+    }
+}
+// --- Graceful Shutdown ---
+async function gracefulShutdown() {
+    console.log("[Log Service] Shutting down...");
+    if (amqpChannel) {
+        try {
+            await amqpChannel.close();
+            console.log("[Log Service] RabbitMQ channel closed.");
+        }
+        catch (e) {
+            console.error("[Log Service] Error closing RabbitMQ channel:", e);
+        }
+    }
+    if (amqpConnection) {
+        try {
+            await amqpConnection.close();
+            console.log("[Log Service] RabbitMQ connection closed.");
+        }
+        catch (e) {
+            console.error("[Log Service] Error closing RabbitMQ connection:", e);
+        }
+    }
+    logStream.end(() => {
+        console.log("[Log Service] Log stream closed.");
+        process.exit(0);
+    });
+}
+process.on("SIGINT", gracefulShutdown);
+process.on("SIGTERM", gracefulShutdown);
+// --- Start the service if run directly ---
+if (require.main === module) {
+    console.log("Log Service started in standalone mode.");
+    startLogService().catch(console.error);
+}
+// Export the start function for PM2
+exports.default = startLogService;

+ 54 - 0
oms/dist/services/message-worker.js

@@ -0,0 +1,54 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const amqplib_1 = __importDefault(require("amqplib"));
+const messageActivityService_1 = require("../src/services/messageActivityService");
+// RabbitMQ连接URL,通常来自环境变量
+const RABBITMQ_URL = process.env.RABBITMQ_URL || "amqp://coloring:coloring123.@localhost:5672";
+// 消息队列的名称
+const QUEUE_NAME = "message-record-generation-queue";
+/**
+ * 启动消息工作进程。
+ * 该函数会连接到 RabbitMQ,监听消息队列,并处理每一条消息。
+ */
+const startWorker = async () => {
+    try {
+        // 建立与 RabbitMQ 的连接
+        const connection = await amqplib_1.default.connect(RABBITMQ_URL);
+        // 创建一个通道
+        const channel = await connection.createChannel();
+        // 声明消息队列,确保它存在
+        await channel.assertQueue(QUEUE_NAME, { durable: true });
+        console.log(" [*] Message worker is running and listening for messages in %s. To exit press CTRL+C", QUEUE_NAME);
+        // 设置预取计数,一次只从队列中取出一条消息进行处理
+        channel.prefetch(1);
+        // 消费消息
+        channel.consume(QUEUE_NAME, async (msg) => {
+            if (msg !== null) {
+                try {
+                    const { activityId } = JSON.parse(msg.content.toString());
+                    console.log(` [x] Received activityId: ${activityId}. Starting record generation...`);
+                    // 使用 MessageActivityService 来生成记录
+                    const service = new messageActivityService_1.MessageActivityService();
+                    const count = await service.generateRecordsForActivity(activityId);
+                    console.log(` [x] Finished processing activity ${activityId}. Generated ${count} records.`);
+                    // 确认消息已处理,将其从队列中移除
+                    channel.ack(msg);
+                }
+                catch (error) {
+                    console.error(` [!] Error processing message: ${msg.content.toString()}`, error);
+                    // 消息处理失败,将其拒绝并重新放回队列
+                    channel.reject(msg, true);
+                }
+            }
+        });
+    }
+    catch (error) {
+        console.error("Failed to connect to RabbitMQ or start worker:", error);
+        // 在这里可以实现重连逻辑,确保 worker 的健壮性
+        setTimeout(startWorker, 5000); // 5秒后尝试重连
+    }
+};
+startWorker();

+ 84 - 0
oms/dist/src/app.js

@@ -0,0 +1,84 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.clickhouseService = exports.redisClient = void 0;
+// oms/src/app.ts
+const dotenv_1 = __importDefault(require("dotenv"));
+dotenv_1.default.config(); // 在读取环境变量之前加载 .env 文件
+const express_1 = __importDefault(require("express"));
+const redis_1 = require("redis");
+const path_1 = __importDefault(require("path"));
+const apiRoutes_1 = __importDefault(require("./routes/apiRoutes"));
+const clickhouseService_1 = require("./services/clickhouseService");
+const database_1 = require("./database");
+const app = (0, express_1.default)();
+const port = process.env.PORT || 3000;
+const mongoUri = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+const redisUri = process.env.REDIS_URI || "redis://redis:6379";
+const clickhouseHost = process.env.CLICKHOUSE_HOST || "http://localhost:8123"; // 👈 ClickHouse 主机
+const clickhouseDatabase = process.env.CLICKHOUSE_DATABASE || "omsdb"; // 👈 ClickHouse 数据库名
+const clickhouseUser = process.env.CLICKHOUSE_USER;
+const clickhousePassword = process.env.CLICKHOUSE_PASSWORD;
+// Initialize ClickhouseService
+const clickhouseService = new clickhouseService_1.ClickhouseService(clickhouseHost, clickhouseDatabase, clickhouseUser, clickhousePassword);
+exports.clickhouseService = clickhouseService;
+const clickhouseTableName = "events"; // ClickHouse 日志表的名称
+// MongoDB connection
+(0, database_1.connectToDatabase)();
+// Redis client
+// Using the modern 'redis' package client setup
+const redisClient = (0, redis_1.createClient)({ url: redisUri }); // 👈 使用解构后的 createClient
+exports.redisClient = redisClient;
+redisClient.on("connect", () => console.log("Connected to Redis"));
+redisClient.on("error", (err) => console.error("Redis connection error:", err));
+// Connect to Redis when the application starts
+(async () => {
+    try {
+        await redisClient.connect();
+        // 确保 ClickHouse 表在应用启动时存在
+        await clickhouseService.ensureTable(clickhouseTableName);
+    }
+    catch (err) {
+        console.error("Failed to connect to essential services:", err);
+        process.exit(1); // 如果连接失败,退出应用
+    }
+})();
+// Middleware
+app.use(express_1.default.json());
+// 新增中间件:为每个API请求添加日志打印
+app.use((req, res, next) => {
+    console.log(`[API Request] ${new Date().toISOString()} - ${req.method} ${req.originalUrl}`);
+    next();
+});
+// API routes
+app.use("/api", apiRoutes_1.default);
+// 动态设置公共目录和Angular应用路径
+let publicPath;
+let angularAppPath;
+if (process.env.NODE_ENV === "production") {
+    // 如果在生产环境中,从 dist 目录往上一层找到 public
+    publicPath = path_1.default.resolve(__dirname, "..", "..", "public");
+    angularAppPath = path_1.default.join(publicPath, "app");
+}
+else {
+    // 如果在开发环境中,public 目录就在当前目录的上一层(相对于src)
+    publicPath = path_1.default.resolve(__dirname, "..", "public");
+    angularAppPath = path_1.default.join(publicPath, "app");
+}
+console.log(process.env.NODE_ENV);
+console.log(`publicPath: ${publicPath}`);
+console.log(`angularAppPath: ${angularAppPath}`);
+// 服务 Angular 应用的静态文件
+// Express 会在 public 目录下寻找请求的静态文件,如 /app/main.js
+app.use(express_1.default.static(publicPath));
+// 作为最终的备用,处理所有未被前面路由处理的请求
+// 这是最标准的单页面应用(SPA)路由处理方式
+app.use((req, res) => {
+    res.sendFile(path_1.default.join(angularAppPath, "index.html"));
+});
+// Start the server
+app.listen(port, () => {
+    console.log(`OMS Backend server listening on port ${port}`);
+});

+ 93 - 0
oms/dist/src/controllers/adminController.js

@@ -0,0 +1,93 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const adminService_1 = __importDefault(require("../services/adminService"));
+const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
+const SECRET_KEY = "Fwcyfyl123."; // 生产环境请务必使用环境变量
+/**
+ * 管理员控制器类,处理与管理员相关的HTTP请求
+ */
+class AdminController {
+    /** 注册新管理员 */
+    async register(req, res) {
+        try {
+            const { username, password } = req.body;
+            const newAdmin = await adminService_1.default.registerAdmin({ username, password });
+            res.status(201).json(newAdmin);
+        }
+        catch (error) {
+            res.status(400).json({ message: error.message });
+        }
+    }
+    /** 管理员登录 */
+    async login(req, res) {
+        try {
+            const { username, password } = req.body;
+            const admin = await adminService_1.default.loginAdmin(username, password);
+            // 生成JWT Token
+            const token = jsonwebtoken_1.default.sign({ id: admin._id, username: admin.username }, SECRET_KEY, { expiresIn: "1d" });
+            res.status(200).json({ message: "Login successful", token });
+        }
+        catch (error) {
+            res.status(401).json({ message: error.message });
+        }
+    }
+    /** 获取所有管理员 */
+    async getAdmins(req, res) {
+        try {
+            const admins = await adminService_1.default.getAdmins();
+            res.status(200).json(admins);
+        }
+        catch (error) {
+            res.status(500).json({ message: error.message });
+        }
+    }
+    /** 根据ID获取单个管理员 */
+    async getAdminById(req, res) {
+        try {
+            const admin = await adminService_1.default.getAdminById(req.params.id);
+            if (!admin) {
+                res.status(404).json({ message: "Admin not found" });
+            }
+            else {
+                res.status(200).json(admin);
+            }
+        }
+        catch (error) {
+            res.status(500).json({ message: error.message });
+        }
+    }
+    /** 根据ID更新管理员 */
+    async updateAdmin(req, res) {
+        try {
+            const updatedAdmin = await adminService_1.default.updateAdmin(req.params.id, req.body);
+            if (!updatedAdmin) {
+                res.status(404).json({ message: "Admin not found" });
+            }
+            else {
+                res.status(200).json(updatedAdmin);
+            }
+        }
+        catch (error) {
+            res.status(400).json({ message: error.message });
+        }
+    }
+    /** 根据ID删除管理员 */
+    async deleteAdmin(req, res) {
+        try {
+            const deletedAdmin = await adminService_1.default.deleteAdmin(req.params.id);
+            if (!deletedAdmin) {
+                res.status(404).json({ message: "Admin not found" });
+            }
+            else {
+                res.status(200).json({ message: "Admin deleted successfully" });
+            }
+        }
+        catch (error) {
+            res.status(500).json({ message: error.message });
+        }
+    }
+}
+exports.default = new AdminController();

+ 239 - 0
oms/dist/src/controllers/artController.js

@@ -0,0 +1,239 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const artService_1 = __importDefault(require("../services/artService")); // Import ArtService
+const artModel_1 = require("../models/artModel"); // Import IArt interface and enums
+const mongoose_1 = __importDefault(require("mongoose")); // Import Mongoose types
+// Define valid keys for Art model for runtime query parameter validation
+const ART_MODEL_QUERY_KEYS = [
+    "status",
+    "pageId",
+    "user",
+    "areaCount",
+    "areaCountFloor",
+    "colorCount",
+    "hasSpecial",
+    "mystery",
+    "ai",
+    "width",
+    "height",
+    "name",
+    "desc",
+    "use",
+    "tags",
+    "epgs",
+    "date",
+    "lastMod",
+    "timeSubmit",
+    "lock",
+    "publishTime",
+    "drop",
+    "totalStartCount",
+    "totalDoneCount",
+    "completionRate",
+];
+class ArtController {
+    /**
+     * Handles requests to update art information.
+     * PUT /api/arts/:id
+     * @param req Express request object
+     * @param res Express response object
+     * @returns Promise<void>
+     */
+    async updateArt(req, res) {
+        try {
+            const { id } = req.params; // Get art ID from URL parameters
+            const updateData = req.body; // Get update data from request body
+            // Validate if ID is a valid MongoDB ObjectId
+            if (!mongoose_1.default.Types.ObjectId.isValid(id)) {
+                res.status(400).json({ message: "Invalid Art ID format." });
+                return;
+            }
+            const updatedArt = await artService_1.default.updateArt(id, updateData);
+            if (updatedArt) {
+                res.status(200).json(updatedArt); // Return the updated art document
+            }
+            else {
+                res.status(404).json({ message: `Art with ID: ${id} not found.` }); // Art not found
+            }
+        }
+        catch (error) {
+            console.error("Failed to update art:", error);
+            res.status(500).json({ message: "Failed to update art", error: error.message }); // Internal server error
+        }
+    }
+    /**
+     * Handles requests to retrieve a paginated list of artworks based on conditions.
+     * GET /api/arts?page=1&limit=30&status=9000&name=example&tags=tag1,tag2&dateStart=2023-01-01&dateEnd=2023-12-31&sortBy=date&sortOrder=desc
+     * @param req Express request object
+     * @param res Express response object
+     * @returns Promise<void>
+     */
+    async getArts(req, res) {
+        try {
+            const page = parseInt(req.query.page) || 1; // Parse page number, default to 1
+            const limit = parseInt(req.query.limit) || 30; // Parse limit per page, default to 30
+            const filters = {}; // Initialize MongoDB filter object
+            const sort = {}; // Initialize sort object
+            // Build filters
+            for (const key of ART_MODEL_QUERY_KEYS) {
+                const queryValue = req.query[key];
+                const startValue = req.query[`${key}Start`]; // For date range start
+                const endValue = req.query[`${key}End`]; // For date range end
+                if (key === "status") {
+                    const statusNum = parseInt(queryValue, 10);
+                    // Validate if status is a valid PageStatus enum value
+                    if (!isNaN(statusNum) && Object.values(artModel_1.PageStatus).includes(statusNum)) {
+                        filters.status = statusNum;
+                    }
+                }
+                else if (key === "name") {
+                    // Fuzzy search for art name (case-insensitive)
+                    filters.name = { $regex: queryValue, $options: "i" };
+                }
+                else if (key === "tags" || key === "epgs") {
+                    // For tags or EPGs array, allow multiple values separated by commas
+                    if (queryValue) {
+                        filters[key] = { $in: queryValue.split(",").map((s) => s.trim()) };
+                    }
+                }
+                else if (key === "hasSpecial" || key === "mystery" || key === "ai" || key === "lock" || key === "drop") {
+                    // Boolean fields
+                    if (queryValue !== undefined) {
+                        filters[key] = queryValue.toLowerCase() === "true";
+                    }
+                }
+                else if (key === "user" || key === "work" || key === "publishBy" || key === "pageId") {
+                    // ObjectId fields
+                    if (queryValue && mongoose_1.default.Types.ObjectId.isValid(queryValue)) {
+                        filters[key] = new mongoose_1.default.Types.ObjectId(queryValue);
+                    }
+                }
+                else if (["date", "lastMod", "timeSubmit", "timeReady", "publishTime"].includes(key)) {
+                    // Date range query for date fields
+                    const dateFilter = {};
+                    let isValidDateQuery = false;
+                    if (startValue) {
+                        try {
+                            const startDate = new Date(startValue);
+                            if (!isNaN(startDate.getTime())) {
+                                dateFilter.$gte = startDate;
+                                isValidDateQuery = true;
+                            }
+                            else {
+                                console.warn(`Invalid start date format for '${key}Start': ${startValue}`);
+                            }
+                        }
+                        catch (e) {
+                            console.warn(`Error parsing start date for '${key}Start': ${startValue}, Error: ${e}`);
+                        }
+                    }
+                    if (endValue) {
+                        try {
+                            const endDate = new Date(endValue);
+                            if (!isNaN(endDate.getTime())) {
+                                dateFilter.$lte = endDate;
+                                isValidDateQuery = true;
+                            }
+                            else {
+                                console.warn(`Invalid end date format for '${key}End': ${endValue}`);
+                            }
+                        }
+                        catch (e) {
+                            console.warn(`Error parsing end date for '${key}End': ${endValue}, Error: ${e}`);
+                        }
+                    }
+                    if (isValidDateQuery) {
+                        filters[key] = dateFilter;
+                    }
+                }
+                else if (
+                // Numeric fields
+                typeof filters[key] === "number" ||
+                    [
+                        "areaCount",
+                        "areaCountFloor",
+                        "coloredAreaCount",
+                        "colorCount",
+                        "orderedColorCount",
+                        "colorOrderStatus",
+                        "mapVersion",
+                        "workVersion",
+                        "centersVersion",
+                        "specialVersion",
+                        "specialPkmVersion",
+                        "useSpecialThumb",
+                        "width",
+                        "height",
+                        "publishVersion",
+                        "order",
+                        "score",
+                        "totalStartCount",
+                        "totalDoneCount",
+                        "completionRate",
+                    ].includes(key)) {
+                    if (queryValue !== undefined) {
+                        const numValue = parseFloat(queryValue);
+                        if (!isNaN(numValue)) {
+                            filters[key] = numValue;
+                        }
+                    }
+                }
+                else if (queryValue !== undefined) {
+                    // Other string type fields, assign directly
+                    filters[key] = queryValue;
+                }
+            }
+            // Build sort conditions
+            if (req.query.sortBy) {
+                const sortByField = req.query.sortBy;
+                // Ensure sort field is a valid key from IArt and exclude non-sortable fields like _id
+                if (ART_MODEL_QUERY_KEYS.includes(sortByField) && sortByField !== "_id") {
+                    const sortOrder = req.query.sortOrder === "asc" || parseInt(req.query.sortOrder) === 1 ? 1 : -1;
+                    sort[sortByField] = sortOrder;
+                }
+            }
+            else {
+                sort.lastMod = -1; // Default sort by last modification time, descending
+            }
+            const { arts, total } = await artService_1.default.getPaginatedArts(page, limit, filters, sort);
+            res.status(200).json({ page, limit, total, arts }); // Return paginated results
+        }
+        catch (error) {
+            console.error("Failed to retrieve art list:", error);
+            res.status(500).json({ message: "Failed to retrieve art list", error: error.message }); // Internal server error
+        }
+    }
+    /**
+     * Handles requests to retrieve a single artwork by ID.
+     * GET /api/arts/:id
+     * @param req Express request object
+     * @param res Express response object
+     * @returns Promise<void>
+     */
+    async getArtById(req, res) {
+        try {
+            const { id } = req.params; // Get art ID from URL parameters
+            // Validate if ID is a valid MongoDB ObjectId
+            if (!mongoose_1.default.Types.ObjectId.isValid(id)) {
+                res.status(400).json({ message: "Invalid Art ID format." });
+                return;
+            }
+            const art = await artService_1.default.getArtById(id);
+            if (art) {
+                res.status(200).json(art); // Return art document
+            }
+            else {
+                res.status(404).json({ message: `Art with ID: ${id} not found.` }); // Art not found
+            }
+        }
+        catch (error) {
+            console.error("Failed to retrieve art:", error);
+            res.status(500).json({ message: "Failed to retrieve art", error: error.message }); // Internal server error
+        }
+    }
+}
+const artController = new ArtController();
+exports.default = artController;

+ 67 - 0
oms/dist/src/controllers/doneRateController.js

@@ -0,0 +1,67 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const doneRateService_1 = __importDefault(require("../services/doneRateService")); // 导入 DoneRateService
+const mongoose_1 = __importDefault(require("mongoose")); // 导入 mongoose 用于验证 ObjectId
+class DoneRateController {
+    /**
+     * 根据作品 ID 获取该作品的所有历史完成率数据。
+     * GET /api/done-rates/artwork/:resId
+     * @param req Express 请求对象
+     * @param res Express 响应对象
+     * @returns Promise<void>
+     */
+    async getDoneRatesByArtworkId(req, res) {
+        try {
+            const { resId } = req.params; // 从 URL 参数获取作品 ID
+            // 验证 resId 是否是有效的 MongoDB ObjectId
+            if (!mongoose_1.default.Types.ObjectId.isValid(resId)) {
+                res.status(400).json({ message: "Invalid artwork ID format." });
+                return;
+            }
+            const doneRates = await doneRateService_1.default.getDoneRatesByArtwork(new mongoose_1.default.Types.ObjectId(resId));
+            if (doneRates && doneRates.length > 0) {
+                res.status(200).json(doneRates); // 返回作品的历史完成率数据
+            }
+            else {
+                res.status(404).json({ message: `No done rates found for artwork ID: ${resId}.` });
+            }
+        }
+        catch (error) {
+            console.error("Failed to retrieve done rates by artwork ID:", error);
+            res.status(500).json({ message: "Failed to retrieve done rates", error: error.message });
+        }
+    }
+    /**
+     * 根据特定日期获取该日所有作品的完成率数据。
+     * GET /api/done-rates/date/:date
+     * @param req Express 请求对象
+     * @param res Express 响应对象
+     * @returns Promise<void>
+     */
+    async getDoneRatesByDate(req, res) {
+        try {
+            const { date } = req.params; // 从 URL 参数获取日期 (YYYYMMDD)
+            // 验证日期格式 (YYYYMMDD)
+            if (!/^\d{8}$/.test(date)) {
+                res.status(400).json({ message: "Invalid date format. Expected YYYYMMDD." });
+                return;
+            }
+            const doneRates = await doneRateService_1.default.getDoneRatesByDate(date);
+            if (doneRates && doneRates.length > 0) {
+                res.status(200).json(doneRates); // 返回指定日期的所有完成率数据
+            }
+            else {
+                res.status(404).json({ message: `No done rates found for date: ${date}.` });
+            }
+        }
+        catch (error) {
+            console.error("Failed to retrieve done rates by date:", error);
+            res.status(500).json({ message: "Failed to retrieve done rates", error: error.message });
+        }
+    }
+}
+const doneRateController = new DoneRateController();
+exports.default = doneRateController;

+ 176 - 0
oms/dist/src/controllers/messageActivityController.js

@@ -0,0 +1,176 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const messageActivityModel_1 = require("../models/messageActivityModel");
+const rabbitmqService_1 = __importDefault(require("../services/rabbitmqService"));
+const QUEUE_NAME = "message-record-generation-queue";
+class MessageActivityController {
+    /**
+     * @route POST /api/message-activity
+     * @desc 创建一个新的消息活动
+     * @access Private
+     */
+    async createActivity(req, res) {
+        try {
+            const newActivity = new messageActivityModel_1.MessageActivity(req.body);
+            await newActivity.save();
+            return res.status(201).json({ success: true, data: newActivity });
+        }
+        catch (error) {
+            console.error("Error creating message activity:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route GET /api/message-activities
+     * @desc 获取所有消息活动
+     * @access Private
+     */
+    async getActivities(req, res) {
+        try {
+            const activities = await messageActivityModel_1.MessageActivity.find().sort({ createdAt: -1 });
+            return res.status(200).json(activities);
+        }
+        catch (error) {
+            console.error("Error fetching message activities:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route GET /api/message-activity/:id
+     * @desc 根据ID获取单个消息活动
+     * @access Private
+     */
+    async getActivityById(req, res) {
+        try {
+            const activity = await messageActivityModel_1.MessageActivity.findById(req.params.id);
+            if (!activity) {
+                return res.status(404).json({ success: false, message: "Message activity not found" });
+            }
+            return res.status(200).json({ success: true, data: activity });
+        }
+        catch (error) {
+            console.error("Error fetching message activity by ID:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route PUT /api/message-activity/:id
+     * @desc 更新消息活动
+     * @access Private
+     */
+    async updateActivity(req, res) {
+        try {
+            const updatedActivity = await messageActivityModel_1.MessageActivity.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
+            if (!updatedActivity) {
+                return res.status(404).json({ success: false, message: "Message activity not found" });
+            }
+            return res.status(200).json({ success: true, data: updatedActivity });
+        }
+        catch (error) {
+            console.error("Error updating message activity:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route DELETE /api/message-activity/:id
+     * @desc 删除消息活动
+     * @access Private
+     */
+    async deleteActivity(req, res) {
+        try {
+            const deletedActivity = await messageActivityModel_1.MessageActivity.findByIdAndDelete(req.params.id);
+            if (!deletedActivity) {
+                return res.status(404).json({ success: false, message: "Message activity not found" });
+            }
+            return res.status(200).json({ success: true, message: "Message activity deleted successfully" });
+        }
+        catch (error) {
+            console.error("Error deleting message activity:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route PUT /api/message-activity/:id/status
+     * @desc 更新消息活动状态
+     * @access Private
+     * @param req.body { status: number } 新状态值
+     */
+    async updateActivityStatus(req, res) {
+        try {
+            const { id } = req.params;
+            const { status } = req.body;
+            // 验证状态值是否合法
+            if (![0, 1, 2, 3].includes(status)) {
+                return res.status(400).json({
+                    success: false,
+                    message: "Invalid status value. Allowed values: 0 (未发布), 1 (已发布), 2 (已完成), 3 (已中止)",
+                });
+            }
+            // 获取当前活动
+            const activity = await messageActivityModel_1.MessageActivity.findById(id);
+            if (!activity) {
+                return res.status(404).json({ success: false, message: "Message activity not found" });
+            }
+            // 验证状态变更是否合法
+            if (!this.isStatusTransitionValid(activity.status, status)) {
+                return res.status(400).json({
+                    success: false,
+                    message: `Invalid status transition from ${this.getStatusName(activity.status)} to ${this.getStatusName(status)}`,
+                });
+            }
+            // 更新状态
+            activity.status = status;
+            // 如果是完成或中止状态,记录完成时间
+            if (status === 2 || status === 3) {
+                activity.completedAt = new Date();
+            }
+            // 如果是发布状态,记录发布时间
+            if (status === 1) {
+                activity.publishedAt = new Date();
+                // 立即向rabbitmq投递一条消息, 以便异步生成消息推送记录
+                await rabbitmqService_1.default.publishActivityMessage(QUEUE_NAME, { activityId: id });
+            }
+            await activity.save();
+            return res.status(200).json({
+                success: true,
+                data: activity,
+                message: `Activity status updated to ${this.getStatusName(status)}`,
+            });
+        }
+        catch (error) {
+            console.error("Error updating activity status:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * 验证状态变更是否合法
+     * @param currentStatus 当前状态
+     * @param newStatus 新状态
+     * @returns 如果变更合法返回true,否则返回false
+     */
+    isStatusTransitionValid(currentStatus, newStatus) {
+        const validTransitions = {
+            0: [1], // 未发布 -> 已发布
+            1: [2, 3], // 已发布 -> 已完成/已中止
+            2: [], // 已完成 -> 不允许变更
+            3: [], // 已中止 -> 不允许变更
+        };
+        return validTransitions[currentStatus]?.includes(newStatus) || false;
+    }
+    /**
+     * 获取状态名称
+     */
+    getStatusName(status) {
+        const statusNames = {
+            0: "未发布",
+            1: "已发布",
+            2: "已完成",
+            3: "已中止",
+        };
+        return statusNames[status] || "未知状态";
+    }
+}
+exports.default = new MessageActivityController();

+ 166 - 0
oms/dist/src/controllers/messageRecordController.js

@@ -0,0 +1,166 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+const messageRecordModel_1 = require("../models/messageRecordModel");
+const mongoose_1 = require("mongoose");
+class MessageRecordController {
+    /**
+     * @route POST /api/message-record
+     * @desc Creates a new message record
+     * @access Private
+     */
+    async createRecord(req, res) {
+        try {
+            const newRecord = new messageRecordModel_1.MessageRecord(req.body);
+            await newRecord.save();
+            return res.status(201).json({ success: true, data: newRecord });
+        }
+        catch (error) {
+            console.error("Error creating message record:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route GET /api/message-records
+     * @desc Retrieves all message records with pagination and optional filters
+     * @access Private
+     */
+    async getPaginatedRecords(req, res) {
+        const { page = 1, limit = 30, uid, activityId, status, startDate, endDate } = req.query;
+        const pageNum = parseInt(page, 10);
+        const limitNum = parseInt(limit, 10);
+        // 动态构建查询过滤器
+        const filters = {};
+        if (uid) {
+            filters.uid = uid;
+        }
+        if (activityId) {
+            // 检查 activityId 是否是有效的 ObjectId 格式
+            if (!(0, mongoose_1.isObjectIdOrHexString)(activityId)) {
+                return res.status(400).json({ success: false, message: "Invalid activityId" });
+            }
+            filters.activityId = activityId;
+        }
+        if (status) {
+            const statusNum = parseInt(status, 30);
+            if (!isNaN(statusNum)) {
+                filters.status = statusNum;
+            }
+        }
+        // 处理日期范围筛选,默认为 createdAt
+        if (startDate || endDate) {
+            filters.createdAt = {};
+            if (startDate) {
+                filters.createdAt.$gte = new Date(startDate);
+            }
+            if (endDate) {
+                filters.createdAt.$lte = new Date(endDate);
+            }
+        }
+        try {
+            const records = await messageRecordModel_1.MessageRecord.find(filters)
+                .sort({ createdAt: -1 })
+                .skip((pageNum - 1) * limitNum)
+                .limit(limitNum);
+            const total = await messageRecordModel_1.MessageRecord.countDocuments(filters);
+            return res.status(200).json({
+                success: true,
+                data: records,
+                pagination: {
+                    total,
+                    page: pageNum,
+                    limit: limitNum,
+                    totalPages: Math.ceil(total / limitNum),
+                },
+            });
+        }
+        catch (error) {
+            console.error("Error fetching paginated records:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route GET /api/message-records/user/:uid
+     * @desc Retrieves message records by user UID
+     * @access Private
+     */
+    async getRecordsByUid(req, res) {
+        try {
+            const records = await messageRecordModel_1.MessageRecord.find({ uid: req.params.uid }).sort({ createdAt: -1 });
+            if (!records || records.length === 0) {
+                return res.status(404).json({ success: false, message: "No records found for this user" });
+            }
+            return res.status(200).json({ success: true, data: records });
+        }
+        catch (error) {
+            console.error("Error fetching records by user UID:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route GET /api/message-records/activity/:activityId
+     * @desc Retrieves message records by activity ID
+     * @access Private
+     */
+    async getRecordsByActivityId(req, res) {
+        try {
+            // 检查 activityId 是否是有效的 ObjectId 格式
+            if (!(0, mongoose_1.isObjectIdOrHexString)(req.params.activityId)) {
+                return res.status(400).json({ success: false, message: "Invalid activityId" });
+            }
+            const records = await messageRecordModel_1.MessageRecord.find({ activityId: req.params.activityId }).sort({ createdAt: -1 });
+            if (!records || records.length === 0) {
+                return res.status(404).json({ success: false, message: "No records found for this activity" });
+            }
+            return res.status(200).json({ success: true, data: records });
+        }
+        catch (error) {
+            console.error("Error fetching records by activity ID:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route GET /api/message-record/:id
+     * @desc Retrieves a single message record by ID
+     * @access Private
+     */
+    async getRecordById(req, res) {
+        try {
+            // 检查 id 是否是有效的 ObjectId 格式
+            if (!(0, mongoose_1.isObjectIdOrHexString)(req.params.id)) {
+                return res.status(400).json({ success: false, message: "Invalid record ID" });
+            }
+            const record = await messageRecordModel_1.MessageRecord.findById(req.params.id);
+            if (!record) {
+                return res.status(404).json({ success: false, message: "Message record not found" });
+            }
+            return res.status(200).json({ success: true, data: record });
+        }
+        catch (error) {
+            console.error("Error fetching message record by ID:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+    /**
+     * @route PUT /api/message-record/:id
+     * @desc Updates the status of a message record
+     * @access Private
+     */
+    async updateRecord(req, res) {
+        try {
+            // 检查 id 是否是有效的 ObjectId 格式
+            if (!(0, mongoose_1.isObjectIdOrHexString)(req.params.id)) {
+                return res.status(400).json({ success: false, message: "Invalid record ID" });
+            }
+            const updatedRecord = await messageRecordModel_1.MessageRecord.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
+            if (!updatedRecord) {
+                return res.status(404).json({ success: false, message: "Message record not found" });
+            }
+            return res.status(200).json({ success: true, data: updatedRecord });
+        }
+        catch (error) {
+            console.error("Error updating message record:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+}
+exports.default = new MessageRecordController();

+ 107 - 0
oms/dist/src/controllers/messageTemplateController.js

@@ -0,0 +1,107 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const messageTemplateService_1 = require("../services/messageTemplateService");
+const mongoose_1 = __importDefault(require("mongoose"));
+class MessageTemplateController {
+    /**
+     * 创建新的消息模板。
+     * POST /api/message-templates
+     */
+    async createTemplate(req, res) {
+        try {
+            const { templateName, messageTitle, messageContent } = req.body;
+            if (!templateName || !messageTitle || !messageContent) {
+                res.status(400).json({ message: "templateName, messageTitle 和 messageContent 都是必需的。" });
+                return;
+            }
+            const newTemplate = await messageTemplateService_1.messageTemplateService.createTemplate(req.body);
+            res.status(201).json(newTemplate);
+        }
+        catch (error) {
+            if (error.code === 11000) {
+                res.status(409).json({ message: "模板名称已存在。", error: error.message });
+            }
+            else if (error instanceof mongoose_1.default.Error.ValidationError) {
+                res.status(400).json({ message: "验证错误。", error: error.message });
+            }
+            else {
+                res.status(500).json({ message: "创建模板时出错。", error: error.message });
+            }
+        }
+    }
+    /**
+     * 根据名称获取消息模板。
+     * GET /api/message-templates/:templateName
+     */
+    async getTemplateByName(req, res) {
+        try {
+            const { templateName } = req.params;
+            const template = await messageTemplateService_1.messageTemplateService.getTemplateByName(templateName);
+            if (template) {
+                res.status(200).json(template);
+            }
+            else {
+                res.status(404).json({ message: "未找到指定的模板。" });
+            }
+        }
+        catch (error) {
+            res.status(500).json({ message: "获取模板时出错。", error: error.message });
+        }
+    }
+    /**
+     * 获取所有消息模板。
+     * GET /api/message-templates
+     */
+    async getAllTemplates(req, res) {
+        try {
+            const templates = await messageTemplateService_1.messageTemplateService.getAllTemplates();
+            res.status(200).json(templates);
+        }
+        catch (error) {
+            res.status(500).json({ message: "获取所有模板时出错。", error: error.message });
+        }
+    }
+    /**
+     * 更新一个消息模板。
+     * PUT /api/message-templates/:templateName
+     */
+    async updateTemplate(req, res) {
+        try {
+            const { templateName } = req.params;
+            const updatedTemplate = await messageTemplateService_1.messageTemplateService.updateTemplate(templateName, req.body);
+            if (updatedTemplate) {
+                res.status(200).json(updatedTemplate);
+            }
+            else {
+                res.status(404).json({ message: "未找到指定的模板。" });
+            }
+        }
+        catch (error) {
+            res.status(500).json({ message: "更新模板时出错。", error: error.message });
+        }
+    }
+    /**
+     * 删除一个消息模板。
+     * DELETE /api/message-templates/:templateName
+     */
+    async deleteTemplate(req, res) {
+        try {
+            const { templateName } = req.params;
+            const result = await messageTemplateService_1.messageTemplateService.deleteTemplate(templateName);
+            if (result.deletedCount && result.deletedCount > 0) {
+                res.status(204).send();
+            }
+            else {
+                res.status(404).json({ message: "未找到指定的模板。" });
+            }
+        }
+        catch (error) {
+            res.status(500).json({ message: "删除模板时出错。", error: error.message });
+        }
+    }
+}
+const messageTemplateController = new MessageTemplateController();
+exports.default = messageTemplateController;

+ 384 - 0
oms/dist/src/controllers/userController.js

@@ -0,0 +1,384 @@
+"use strict";
+// oms/src/controller/userController.ts
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const userService_1 = __importDefault(require("../services/userService")); // 导入用户服务
+const fcmService_1 = require("../services/fcmService");
+const messageRecordModel_1 = require("../models/messageRecordModel");
+const utils_1 = require("../libs/utils");
+// Define an array of valid IUser keys for runtime checking
+// This ensures that only properties defined in IUser are considered for MongoDB queries
+const USER_MODEL_KEYS = [
+    "uid",
+    "project",
+    "network",
+    "campaign",
+    "adgroup",
+    "creative",
+    "prod",
+    "libraryName",
+    "cc",
+    "lang",
+    "manufacturer",
+    "deviceModel",
+    "deviceInfo",
+    "hardware",
+    "deviceMem",
+    "apiLevel",
+    "versionName",
+    "versionCode",
+    "fmToken",
+    "tags",
+    "firstLoginAt",
+    "lastActiveAt",
+];
+class UserController {
+    /**
+     * 处理创建新用户的请求。
+     * POST /api/users
+     */
+    async createUser(req, res) {
+        try {
+            const { uid, ...otherData } = req.body; // uid 现在是必需的 // 检查 uid 是否存在
+            if (!uid) {
+                res.status(400).json({ message: "用户 UID 是必需的。" });
+                return;
+            } // 如果 uid 存在,则直接用 req.body 来创建用户
+            const user = await userService_1.default.createUser(req.body);
+            res.status(201).json(user);
+        }
+        catch (error) {
+            // 检查是否是重复键错误 (uid 唯一性冲突)
+            if (error.message.includes("E11000 duplicate key error") || error.message.includes("duplicate key")) {
+                res.status(409).json({
+                    message: "用户 UID 已存在,请使用其他 UID。",
+                    error: error.message,
+                });
+            }
+            else {
+                res.status(500).json({ message: "创建用户时出错", error: error.message });
+            }
+        }
+    }
+    /**
+     * 处理通过 UID 获取用户的请求。
+     * GET /api/users/:uid
+     */
+    async getUserByUid(req, res) {
+        try {
+            const { uid } = req.params;
+            const user = await userService_1.default.getUserByUid(uid);
+            if (user) {
+                res.status(200).json(user);
+            }
+            else {
+                res.status(404).json({ message: `未找到 UID 为 ${uid} 的用户。` });
+            }
+        }
+        catch (error) {
+            res.status(500).json({ message: "获取用户时出错", error: error.message });
+        }
+    }
+    /**
+     * 处理更新用户的请求。
+     * PUT /api/users/:uid
+     */
+    async updateUser(req, res) {
+        try {
+            const { uid } = req.params;
+            const updateData = req.body; // 业务逻辑检查:不允许直接通过 PUT 请求更新 UID
+            if (updateData.uid && updateData.uid !== uid) {
+                res.status(400).json({ message: "不允许修改用户 UID。" });
+                return;
+            }
+            delete updateData.uid; // 确保不会尝试更新 UID
+            const updatedUser = await userService_1.default.updateUser(uid, updateData);
+            if (updatedUser) {
+                res.status(200).json(updatedUser);
+            }
+            else {
+                res.status(404).json({ message: `未找到 UID 为 ${uid} 的用户。` });
+            }
+        }
+        catch (error) {
+            res.status(500).json({ message: "更新用户时出错", error: error.message });
+        }
+    }
+    /**
+     * 处理删除用户的请求。
+     * DELETE /api/users/:uid
+     */
+    async deleteUser(req, res) {
+        try {
+            const { uid } = req.params;
+            const deleted = await userService_1.default.deleteUser(uid);
+            if (deleted) {
+                res.status(204).send(); // 204 No Content,表示成功删除但无返回内容
+            }
+            else {
+                res.status(404).json({ message: `未找到 UID 为 ${uid} 的用户。` });
+            }
+        }
+        catch (error) {
+            res.status(500).json({ message: "删除用户时出错", error: error.message });
+        }
+    }
+    /**
+     * 处理获取所有用户或按分页和查询参数获取用户列表的请求。
+     * GET /api/users?page=1&limit=10&project=1&cc=US
+     */
+    async getPaginatedUsers(req, res) {
+        try {
+            const page = parseInt(req.query.page) || 1;
+            const limit = parseInt(req.query.limit) || 30; // 从 req.query 中筛选出只与 IUser 相关的属性,作为 MongoDB 的查询条件
+            const mongooseQuery = {};
+            for (const key in req.query) {
+                if (Object.prototype.hasOwnProperty.call(req.query, key)) {
+                    // 检查 key 是否在 USER_MODEL_KEYS 中 (即是否是 IUser 的有效属性)
+                    // 并且不是分页参数 'page' 或 'limit'
+                    if (USER_MODEL_KEYS.includes(key)) {
+                        const queryValue = req.query[key]; // 根据需要进行类型转换
+                        if (key === "project" || key === "apiLevel" || key === "versionCode") {
+                            const numValue = parseInt(queryValue);
+                            if (!isNaN(numValue)) {
+                                mongooseQuery[key] = numValue;
+                            }
+                        }
+                        else if (key === "deviceMem") {
+                            const numValue = parseFloat(queryValue);
+                            if (!isNaN(numValue)) {
+                                mongooseQuery[key] = numValue;
+                            }
+                        }
+                        else if (key === "tags") {
+                            // 如果 tags 是以逗号分隔的字符串,可以将其转换为数组
+                            mongooseQuery[key] = queryValue.split(",").map((s) => s.trim());
+                        }
+                        else if (key === "firstLoginAt" || key === "lastActiveAt") {
+                            // 尝试将字符串转换为 Date 对象
+                            try {
+                                const dateValue = new Date(queryValue);
+                                if (!isNaN(dateValue.getTime())) {
+                                    // 检查日期是否有效
+                                    mongooseQuery[key] = dateValue;
+                                }
+                            }
+                            catch (e) {
+                                console.warn(`Invalid date format for ${key}: ${queryValue}. Skipping.`); // 可以选择在这里返回错误或忽略该查询参数
+                            }
+                        } // 对于其他字符串类型,直接赋值
+                        else {
+                            mongooseQuery[key] = queryValue;
+                        }
+                    }
+                }
+            }
+            const { users, total } = await userService_1.default.getPaginatedUsers(page, limit, mongooseQuery // 传入处理后的查询对象
+            );
+            res.status(200).json({
+                page,
+                limit,
+                total,
+                users,
+            });
+        }
+        catch (error) {
+            res.status(500).json({ message: "获取用户列表时出错", error: error.message });
+        }
+    }
+    /**
+     * 点对点发送消息给指定 UID 的用户。
+     * POST /api/users/send-message
+     */
+    async sendDirectMessage(req, res) {
+        const { uid, title, content, image, bigger, action, param, extend } = req.body;
+        // 1. 验证必需参数
+        if (!uid || !title || !content) {
+            res.status(400).json({ message: "UID, title, and content are required." });
+            return;
+        }
+        try {
+            // 2. 找到用户并获取 FCM Token
+            const user = await userService_1.default.getUserByUid(uid);
+            if (!user || !user.fmToken) {
+                // 用户不存在或没有FCM Token,直接创建失败记录并返回
+                await messageRecordModel_1.MessageRecord.create({
+                    uid,
+                    title,
+                    content,
+                    image,
+                    bigger,
+                    action,
+                    param,
+                    extend,
+                    plannedSendAt: new Date(),
+                    actualSendAt: new Date(),
+                    status: -1,
+                    errno: "User not found or no FCM token",
+                });
+                res.status(404).json({ message: `User with UID ${uid} not found or no FCM token.` });
+                return;
+            }
+            // 3. 先创建 MessageRecord 记录
+            const messageRecord = new messageRecordModel_1.MessageRecord({
+                uid,
+                title,
+                content,
+                image,
+                bigger,
+                action: action || "go/app", // 默认 action
+                param,
+                extend,
+                plannedSendAt: new Date(),
+                status: 0, // 初始状态为 0(未发送)
+            });
+            await messageRecord.save();
+            // 4. 获取记录的 _id 作为消息 ID
+            const fcmService = fcmService_1.FCMService.getInstance();
+            const messageData = (0, utils_1.filterEmptyProps)({
+                msgid: messageRecord._id.toString(), // 使用数据库记录的 _id 作为消息 ID
+                title,
+                content,
+                image: image || "",
+                bigger: bigger?.toString() || "false",
+                action: action || "go/app",
+                param: param || "",
+                extend: extend || "",
+            });
+            // 5. 调用 FCMService 发送消息
+            const result = await fcmService.sendMessage(user.fmToken, messageData);
+            let messageStatus = 1;
+            let errorMessage = null;
+            let fcmReceipt = null;
+            if (result instanceof Error) {
+                messageStatus = -1;
+                errorMessage = result.message;
+                console.warn(`send message to ${uid} failed: ${result.message}`);
+            }
+            else {
+                fcmReceipt = result;
+                console.log(`send message to ${uid} success!`);
+            }
+            // 6. 更新 MessageRecord 记录
+            await messageRecordModel_1.MessageRecord.findByIdAndUpdate(messageRecord._id, {
+                status: messageStatus,
+                actualSendAt: new Date(),
+                errno: errorMessage,
+                fcmReceipt,
+            });
+            if (messageStatus === 1) {
+                res.status(200).json({ message: "Message sent successfully.", fcmReceipt });
+            }
+            else {
+                res.status(500).json({ message: "Failed to send message.", error: errorMessage });
+            }
+        }
+        catch (error) {
+            // 7. 捕获并处理所有其他异常
+            console.error(`Error sending message to UID ${uid}:`, error);
+            res.status(500).json({ message: "An unexpected error occurred while sending the message.", error: error.message });
+        }
+    }
+    /**
+     * Handles bulk messaging to an array of UIDs.
+     * POST /api/users/send-bulk-message
+     */
+    async sendBulkMessage(req, res) {
+        const { uids, title, content, image, bigger, action, param, extend } = req.body;
+        // 1. Validate required parameters.
+        if (!uids || !Array.isArray(uids) || uids.length === 0 || !title || !content) {
+            res.status(400).json({ message: "An array of uids, title, and content are required." });
+            return;
+        }
+        const results = [];
+        // 2. Use Promise.all to handle concurrent message sending.
+        const promises = uids.map(async (uid) => {
+            try {
+                const user = await userService_1.default.getUserByUid(uid);
+                if (!user || !user.fmToken) {
+                    // User not found or no FCM Token. Create a failure record.
+                    const failedRecord = await messageRecordModel_1.MessageRecord.create({
+                        uid,
+                        title,
+                        content,
+                        image,
+                        bigger,
+                        action,
+                        param,
+                        extend,
+                        plannedSendAt: new Date(),
+                        actualSendAt: new Date(),
+                        status: -1,
+                        errno: "User not found or no FCM token",
+                    });
+                    results.push({ uid, status: "failed", error: "User not found or no FCM token", recordId: failedRecord._id });
+                    return;
+                }
+                // Create a MessageRecord document before sending.
+                const messageRecord = new messageRecordModel_1.MessageRecord({
+                    uid,
+                    title,
+                    content,
+                    image,
+                    bigger,
+                    action: action || "go/app",
+                    param,
+                    extend,
+                    plannedSendAt: new Date(),
+                    status: 0,
+                });
+                await messageRecord.save();
+                const fcmService = fcmService_1.FCMService.getInstance();
+                const messageData = (0, utils_1.filterEmptyProps)({
+                    msgid: messageRecord._id.toString(),
+                    title,
+                    content,
+                    image: image || "",
+                    bigger: bigger?.toString() || "false",
+                    action: action || "go/app",
+                    param: param || "",
+                    extend: extend || "",
+                });
+                // Send message.
+                const result = await fcmService.sendMessage(user.fmToken, messageData);
+                let messageStatus = 1;
+                let errorMessage = null;
+                let fcmReceipt = null;
+                if (result instanceof Error) {
+                    messageStatus = -1;
+                    errorMessage = result.message;
+                }
+                else {
+                    fcmReceipt = result;
+                }
+                // Update the MessageRecord with the send result.
+                await messageRecordModel_1.MessageRecord.findByIdAndUpdate(messageRecord._id, {
+                    status: messageStatus,
+                    actualSendAt: new Date(),
+                    errno: errorMessage,
+                    fcmReceipt,
+                });
+                if (messageStatus === 1) {
+                    results.push({ uid, status: "success", fcmReceipt, recordId: messageRecord._id });
+                }
+                else {
+                    results.push({ uid, status: "failed", error: errorMessage, recordId: messageRecord._id });
+                }
+            }
+            catch (error) {
+                console.error(`Error processing bulk message for UID ${uid}:`, error);
+                results.push({ uid, status: "failed", error: error.message });
+            }
+        });
+        await Promise.all(promises);
+        res.status(200).json({
+            message: "Bulk message sending complete. See details for each recipient.",
+            totalRecipients: uids.length,
+            results,
+        });
+    }
+}
+const userController = new UserController();
+exports.default = userController;

+ 31 - 0
oms/dist/src/controllers/userTargetingController.js

@@ -0,0 +1,31 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.UserTargetingController = void 0;
+const userTargetingService_1 = require("../services/userTargetingService");
+const userTargetingService = new userTargetingService_1.UserTargetingService();
+class UserTargetingController {
+    /**
+     * @route POST /api/users/count
+     * @desc 根据筛选条件查询满足条件的用户数
+     * @access Private (或根据需要调整)
+     */
+    async countTargetUsers(req, res) {
+        try {
+            // 从请求体中获取筛选条件数组
+            const { filters } = req.body;
+            console.log(filters);
+            if (!filters || !Array.isArray(filters)) {
+                return res.status(400).json({ success: false, message: "Invalid or missing 'filters' array in request body." });
+            }
+            // 调用服务层方法获取用户数量
+            const userCount = await userTargetingService.countTargetUsers(filters);
+            return res.status(200).json(userCount);
+        }
+        catch (error) {
+            console.error("Error counting target users from API request:", error);
+            return res.status(500).json({ success: false, message: "Server error", error: error.message });
+        }
+    }
+}
+exports.UserTargetingController = UserTargetingController;
+exports.default = new UserTargetingController();

+ 44 - 0
oms/dist/src/database.js

@@ -0,0 +1,44 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.disconnectFromDatabase = exports.connectToDatabase = void 0;
+const mongoose_1 = __importDefault(require("mongoose"));
+// MongoDB connection URL. This should be configured in your environment variables.
+const MONGO_URL = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+/**
+ * Connects to the MongoDB database.
+ * This function should be called once at the application's startup.
+ * @returns {Promise<void>}
+ */
+const connectToDatabase = async () => {
+    try {
+        if (mongoose_1.default.connection.readyState === 1) {
+            console.log("[Database] Already connected to MongoDB.");
+            return;
+        }
+        await mongoose_1.default.connect(MONGO_URL);
+        console.log("[Database] Successfully connected to MongoDB.");
+    }
+    catch (error) {
+        console.error("[Database] Failed to connect to MongoDB:", error);
+        process.exit(1); // Exit process if connection fails
+    }
+};
+exports.connectToDatabase = connectToDatabase;
+/**
+ * Disconnects from the MongoDB database.
+ * This function should be called on application shutdown.
+ * @returns {Promise<void>}
+ */
+const disconnectFromDatabase = async () => {
+    try {
+        await mongoose_1.default.disconnect();
+        console.log("[Database] Disconnected from MongoDB.");
+    }
+    catch (error) {
+        console.error("[Database] Error disconnecting from MongoDB:", error);
+    }
+};
+exports.disconnectFromDatabase = disconnectFromDatabase;

+ 15 - 0
oms/dist/src/libs/utils.js

@@ -0,0 +1,15 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.filterEmptyProps = void 0;
+// 工具函数:过滤掉对象中值为空的属性
+const filterEmptyProps = (obj) => {
+    return Object.entries(obj).reduce((acc, [key, value]) => {
+        // 定义空值判断:排除 null、undefined、空字符串
+        const isEmpty = value === null || value === undefined || value === "";
+        if (!isEmpty) {
+            acc[key] = value;
+        }
+        return acc;
+    }, {});
+};
+exports.filterEmptyProps = filterEmptyProps;

+ 33 - 0
oms/dist/src/middleware/authMiddleware.js

@@ -0,0 +1,33 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.authMiddleware = void 0;
+const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
+// 定义一个 JWT 签名密钥
+const JWT_SECRET = "Fwcyfyl123."; // ⚠ 生产环境请使用环境变量
+/**
+ * JWT 认证中间件
+ * 验证请求头中的 JWT Token
+ */
+const authMiddleware = (req, res, next) => {
+    // 从请求头中获取 Token
+    const authHeader = req.headers.authorization;
+    if (!authHeader || !authHeader.startsWith("Bearer ")) {
+        return res.status(401).json({ message: "Authorization token not found or invalid format." });
+    }
+    const token = authHeader.split(" ")[1];
+    try {
+        // 验证 Token 并解码
+        const decodedToken = jsonwebtoken_1.default.verify(token, JWT_SECRET);
+        // 将解码后的用户信息附加到请求对象上
+        req.user = decodedToken;
+        next(); // 继续处理请求
+    }
+    catch (error) {
+        // Token 验证失败
+        return res.status(401).json({ message: "Invalid or expired token." });
+    }
+};
+exports.authMiddleware = authMiddleware;

+ 66 - 0
oms/dist/src/models/adminModel.js

@@ -0,0 +1,66 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+const mongoose_1 = __importStar(require("mongoose"));
+// 定义管理员用户的 Mongoose Schema
+const AdminSchema = new mongoose_1.Schema({
+    // 用户名,确保唯一且移除两端空白字符
+    username: {
+        type: String,
+        required: true,
+        unique: true,
+        trim: true,
+        minlength: 3,
+    },
+    // 密码,需要最少6个字符
+    password: {
+        type: String,
+        required: true,
+        minlength: 6,
+    },
+    // 标识是否为管理员,默认值为true
+    isAdmin: {
+        type: Boolean,
+        default: true,
+    },
+    // 记录创建时间
+    createdAt: {
+        type: Date,
+        default: Date.now,
+    },
+});
+// 创建并导出 Admin 模型
+const Admin = mongoose_1.default.model("Admin", AdminSchema);
+exports.default = Admin;

+ 138 - 0
oms/dist/src/models/artModel.js

@@ -0,0 +1,138 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.PageStatus = exports.SpecialThumbType = void 0;
+// oms/src/models/artModel.ts
+const mongoose_1 = __importStar(require("mongoose"));
+// --- 定义常量 (使用 TypeScript 枚举或常量对象) ---
+// Special 缩略图类型
+var SpecialThumbType;
+(function (SpecialThumbType) {
+    SpecialThumbType[SpecialThumbType["OUTLINE"] = 0] = "OUTLINE";
+    SpecialThumbType[SpecialThumbType["GRAY"] = 1] = "GRAY";
+    SpecialThumbType[SpecialThumbType["UPLOAD"] = 2] = "UPLOAD";
+    SpecialThumbType[SpecialThumbType["GRADIENT"] = 3] = "GRADIENT";
+})(SpecialThumbType || (exports.SpecialThumbType = SpecialThumbType = {}));
+// 页面状态
+var PageStatus;
+(function (PageStatus) {
+    PageStatus[PageStatus["REFUSE"] = 500] = "REFUSE";
+    PageStatus[PageStatus["NEW"] = 1000] = "NEW";
+    PageStatus[PageStatus["MODIFY"] = 2000] = "MODIFY";
+    PageStatus[PageStatus["TESTING"] = 3000] = "TESTING";
+    PageStatus[PageStatus["READY"] = 7000] = "READY";
+    PageStatus[PageStatus["OFFLINE"] = 8000] = "OFFLINE";
+    PageStatus[PageStatus["ONLINE"] = 9000] = "ONLINE";
+})(PageStatus || (exports.PageStatus = PageStatus = {}));
+// 定义 Art Schema
+const ArtSchema = new mongoose_1.Schema({
+    status: { type: Number, required: true, index: true, default: PageStatus.NEW, enum: Object.values(PageStatus) /* desc: '状态' */ },
+    pageId: { type: mongoose_1.Schema.Types.ObjectId, required: true /* desc: 'Page Id' */ },
+    user: { type: mongoose_1.Schema.Types.ObjectId, ref: "Designer", required: true, index: true /* desc: '作者' */ },
+    from: { type: String /* desc: '素材来源' */ },
+    work: { type: mongoose_1.Schema.Types.ObjectId, ref: "ArtBin" /*desc: '上色效果',*/ }, // 👈 关键修改:添加 work 字段到 Schema
+    pageVersion: { type: Number, default: 1 /* desc: '底稿版本' */ },
+    areaCount: { type: Number, required: true /* desc: '区块数' */ },
+    areaCountFloor: { type: Number, required: true, index: true /* desc: '区块数/100向下取整' */ },
+    coloredAreaCount: { type: Number, default: 0 /* desc: '填色区块数' */ },
+    colorCount: { type: Number, default: 0 /* desc: '颜色数' */ },
+    orderedColorCount: { type: Number, default: 0 /* desc: '已排序颜色数' */ },
+    colorOrderStatus: { type: Number, default: 0 /* desc: '排序状态' */ },
+    mapVersion: { type: Number, required: true, default: 1 /* desc: 'map版本' */ },
+    workVersion: { type: Number, required: true, default: 1 /* desc: 'work版本' */ },
+    centersVersion: { type: Number, required: true, default: 0 /* desc: '中心点版本' */ },
+    hasSpecial: { type: Boolean, default: false, index: true /* desc: '是否有special' */ },
+    specialVersion: { type: Number, default: 0 /* desc: 'Special图版本' */ },
+    specialPkmVersion: { type: Number, default: 0 /* desc: 'SpecialPkm版本' */ },
+    useSpecialThumb: { type: Number, required: true, default: SpecialThumbType.OUTLINE, enum: Object.values(SpecialThumbType) /* desc: '当前Special缩略图' */ },
+    mystery: { type: Boolean, default: false, index: true /* desc: '是否神秘图' */ },
+    ai: { type: Boolean, default: false, index: true /* desc: '是否AI参考图' */ },
+    aiPrompt: { type: String /* desc: 'AI关键字' */ },
+    width: { type: Number, required: true, index: true /* desc: '宽' */ },
+    height: { type: Number, index: true, required: true /* desc: '高' */ },
+    name: { type: String, required: true /* desc: '作品名' */ },
+    desc: { type: String /* desc: '作品描述' */ },
+    use: { type: String, required: true, index: true, default: "normal", lowercase: true, trim: true /* desc: '用途' */ },
+    tags: { type: [String], index: true, lowercase: true, trim: true /* desc: '标签' */ },
+    epgs: { type: [String], index: true, trim: true /* desc: 'EPG' */ },
+    date: { type: Date, default: Date.now, index: true /* desc: '上传时间' */ },
+    lastMod: { type: Date, default: Date.now, index: true /* desc: '修改时间' */ },
+    timeSubmit: { type: Date, index: true /* desc: '提测时间' */ },
+    timeReady: { type: Date, index: true /* desc: '通过时间' */ },
+    refuseReason: { type: String /* desc: '拒稿原因' */ },
+    lock: { type: Boolean, default: false, index: true /* desc: '是否上锁' */ },
+    publishVersion: { type: Number, default: 0, index: true /* desc: '发布版本' */ },
+    publishTime: { type: Date, index: true /* desc: '发布时间' */ },
+    publishBy: { type: mongoose_1.Schema.Types.ObjectId, ref: "Designer", index: true /* desc: '发布者' */ },
+    order: { type: Number, default: 0, index: true },
+    drop: { type: Boolean, default: false, index: true /* desc: '标记删除' */ },
+    dropReason: { type: String /* desc: '删除原因' */ },
+    score: { type: Number /* desc: '评分' */ },
+    ////////////////////////// 统计数据 ////////////////////////////
+    totalStartCount: { type: Number, index: true /* desc: '总开始数' */ },
+    totalDoneCount: { type: Number, index: true /* desc: '总完成数' */ },
+    completionRate: { type: Number, index: true /* desc: '完成比例' */ },
+}, {
+    strict: true,
+    toJSON: {
+        virtuals: true,
+        transform: artTransform,
+    },
+    toObject: {
+        virtuals: true,
+        transform: artTransform,
+    },
+});
+// Art 转换函数 (虚拟字段 thumb 的逻辑)
+function artTransform(doc, ret) {
+    // make thumb for art.
+    // 确保 doc.lastMod 存在或提供默认值,以避免运行时错误
+    const lastModTime = doc.lastMod ? doc.lastMod.getTime() : Date.now();
+    ret.thumb = `/thumbs/v2/page/320/${doc.pageId}.png?t=${lastModTime}`;
+    if (doc.work && doc.hasSpecial) {
+        ret.thumb = `/thumbs/v2/special/320/${doc._id}.png?t=${lastModTime}`;
+    }
+    else if (doc.work) {
+        ret.thumb = `/thumbs/v2/work/320/${doc._id}.png?t=${lastModTime}`;
+    }
+}
+// 复合索引
+ArtSchema.index({
+    order: -1,
+    publishTime: -1,
+});
+// 创建并导出 Art Model
+const Art = mongoose_1.default.model("Art", ArtSchema);
+exports.default = Art;

+ 70 - 0
oms/dist/src/models/colorRecordModel.js

@@ -0,0 +1,70 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+// oms/src/models/colorRecordModel.ts
+const mongoose_1 = __importStar(require("mongoose"));
+// 定义 ColorRecord Schema
+const ColorRecordSchema = new mongoose_1.Schema({
+    uid: {
+        type: String,
+        required: true,
+        index: true, // 为 uid 字段添加索引
+    },
+    res: {
+        type: String,
+        required: true,
+        index: true, // 为 作品id (res) 字段添加索引
+    },
+    event: {
+        type: String,
+        required: true,
+        enum: ["color_start", "color_done", "color_tip"], // 限制操作类型为枚举值
+        index: true, // 为 type 字段添加索引
+    },
+    time: {
+        type: Date,
+        required: true,
+        index: true, // 为 操作时间 (time) 字段添加索引
+    },
+    duration: {
+        type: Number,
+        min: 0, // 确保时长不为负数
+    },
+}, {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt 字段
+});
+// 创建并导出 ColorRecord Model
+const ColorRecord = mongoose_1.default.model("ColorRecord", ColorRecordSchema);
+exports.default = ColorRecord;

+ 79 - 0
oms/dist/src/models/doneRateModel.js

@@ -0,0 +1,79 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+// oms/src/models/doneRateModel.ts
+const mongoose_1 = __importStar(require("mongoose"));
+// 定义 DoneRate Schema
+const DoneRateSchema = new mongoose_1.Schema({
+    date: {
+        type: String,
+        required: true,
+        index: true, // 为日期添加索引,有助于按日期查询
+        match: /^\d{8}$/, // 验证日期格式是否为 YYYYMMDD
+    },
+    res: {
+        type: mongoose_1.Schema.Types.ObjectId,
+        ref: "Art", // 关联到 Art 模型
+        required: true,
+        index: true, // 为作品 ID 添加索引
+    },
+    startCount: {
+        type: Number,
+        required: true,
+        default: 0,
+        min: 0, // 确保开始次数不为负
+    },
+    doneCount: {
+        type: Number,
+        required: true,
+        default: 0,
+        min: 0, // 确保完成次数不为负
+    },
+    completionRate: {
+        type: Number,
+        required: true,
+        default: 0,
+        min: 0, // 完成率不为负
+        max: 100, // 完成率不超100
+    },
+}, {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt 字段
+});
+// 为 date 和 res 字段创建复合唯一索引
+// 这确保了在给定日期,每个作品只有一条完成率记录
+DoneRateSchema.index({ date: 1, res: 1 }, { unique: true });
+// 创建并导出 DoneRate Model
+const DoneRate = mongoose_1.default.model("DoneRate", DoneRateSchema);
+exports.default = DoneRate;

+ 75 - 0
oms/dist/src/models/messageActivityModel.js

@@ -0,0 +1,75 @@
+"use strict";
+// oms/src/models/messageActivityModal.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MessageActivity = void 0;
+const mongoose_1 = require("mongoose");
+// Mongoose schema for MessageActivity
+const messageActivitySchema = new mongoose_1.Schema({
+    // 此名称用于标识此通知活动,不会向用户显示
+    name: {
+        type: String,
+        required: true,
+        unique: true,
+        trim: true,
+    },
+    //消息模板, 连接到消息模版表MessageTemplate, 实际上就是确定消息标题和文本
+    templateId: {
+        type: mongoose_1.Schema.Types.ObjectId,
+        ref: "MessageTemplate", // Reference to the MessageTemplate model
+    },
+    // 通知图片url
+    image: {
+        type: String,
+    },
+    // 消息是否允许展开,有图片的情况下有效
+    bigger: {
+        type: Boolean,
+        default: false,
+    },
+    // 定义客户端收到消息后的行为,如 go/app , open/art 等
+    action: {
+        type: String,
+        default: "go/app",
+    },
+    // 消息参数
+    param: {
+        type: String,
+    },
+    // 消息扩展参数
+    extend: {
+        type: String,
+    },
+    // 推送策略, 系统采用硬编码的形式,预制了若干推送策略,此处的策略编号将对应不同的用户定位逻辑, 默认0则表示不采用任何系统硬编码策略。 若指定系统策略,则系统策略有限,下面的用户筛选条件,推送时间计划可能将会忽略作废
+    strategy: {
+        type: Number,
+        default: 0,
+    },
+    // 目标用户筛选条件,存储为原生数组
+    filter: [
+        {
+            field: { type: String, required: true },
+            operator: { type: String, required: true },
+            value: mongoose_1.Schema.Types.Mixed, // 存储任意类型的值
+        },
+    ],
+    // 计划推送时间
+    scheduleAt: {
+        type: Date,
+    },
+    publishedAt: { type: Date }, // 发布时间
+    completedAt: { type: Date }, // 完成/中止时间
+    lastPubDate: { type: Date }, // 上次推送日期
+    // 是否周期每天推送
+    everyday: {
+        type: Boolean,
+        default: false,
+    },
+    // 消息通知活动状态:0-未发布, 1-已发布(进行中), 2-已完成, 3-已中止。
+    status: {
+        type: Number,
+        default: 0, // 0-unreleased; 1-published; 2-completed
+    },
+}, {
+    timestamps: true, // Automatically adds createdAt and updatedAt fields
+});
+exports.MessageActivity = (0, mongoose_1.model)("MessageActivity", messageActivitySchema);

+ 98 - 0
oms/dist/src/models/messageRecordModel.js

@@ -0,0 +1,98 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MessageRecord = void 0;
+const mongoose_1 = require("mongoose");
+// Mongoose schema for MessageRecord
+const messageRecordSchema = new mongoose_1.Schema({
+    // 目标用户uid
+    uid: {
+        type: String,
+        required: true,
+        index: true, // Index for faster lookup by user
+    },
+    // 消息活动, 关联到消息活动表(messageActivity),表明次消息记录来自于那个宣传活动;有可能是空值,对于点对点发送消息的情况可能没有关联的活动
+    activityId: {
+        type: mongoose_1.Schema.Types.ObjectId,
+        ref: "MessageActivity",
+        required: false, // It's optional as per design
+        index: true, // Added index for better query performance by activity
+    },
+    // 关联的消息模版表,不太重要了,也可能是空值,对于点对点发送消息的情况可能没有模版
+    templateId: {
+        type: mongoose_1.Schema.Types.ObjectId,
+        ref: "MessageTemplate",
+        required: false, // It's optional as per design
+    },
+    // 已经确定了具体语言的消息标题,必须
+    title: {
+        type: String,
+        required: true,
+    },
+    // 已经确定了具体语言的消息内容,必须
+    content: {
+        type: String,
+        required: true,
+    },
+    // 图片url, 非必须
+    image: {
+        type: String,
+    },
+    // 消息通知的消息是否点击展开查看大图。没有图片的情况下一般就设置成false
+    bigger: {
+        type: Boolean,
+        default: false,
+    },
+    // 定义客户端收到消息后的行为,如 go/app , open/art 参看前文
+    action: {
+        type: String,
+    },
+    // 消息参数
+    param: {
+        type: String,
+    },
+    // 消息扩展参数
+    extend: {
+        type: String,
+    },
+    // 0-未发送;1-发送成功;2-已送达;3-已打开;-1-发送失败
+    status: {
+        type: Number,
+        default: 0, // 0-not sent; 1-sent successfully; 2-delivered; 3-opened; -1-send failed
+        index: true,
+    },
+    // true: 在前台; false: 在后台
+    inforeground: {
+        type: Boolean,
+    },
+    // 捕捉firebase admin sdk api调用失败的信息填充到这里
+    errno: {
+        type: String,
+    },
+    // 消息发送成功的回执,其实就是FCM用于标识消息的id
+    fcmReceipt: {
+        type: String,
+        index: true, // Index for faster lookup by FCM receipt
+    },
+    // 新增字段
+    plannedSendAt: {
+        type: Date,
+        index: true,
+    },
+    actualSendAt: {
+        type: Date,
+        index: true,
+    },
+    deliveredAt: {
+        type: Date,
+        index: true,
+    },
+    openedAt: {
+        type: Date,
+        index: true,
+    },
+}, {
+    timestamps: true, // Automatically adds createdAt and updatedAt
+});
+// Added compound index for common queries by user and status
+messageRecordSchema.index({ uid: 1, status: 1 });
+exports.MessageRecord = (0, mongoose_1.model)("MessageRecord", messageRecordSchema);

+ 49 - 0
oms/dist/src/models/messageTemplateModel.js

@@ -0,0 +1,49 @@
+"use strict";
+// oms/src/models/messageTemplateModel.ts
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MessageTemplate = void 0;
+const mongoose_1 = __importStar(require("mongoose"));
+// 定义消息模板的 Mongoose Schema
+const MessageTemplateSchema = new mongoose_1.Schema({
+    templateName: { type: String, required: true, unique: true, trim: true }, // 模板的唯一名称,方便在代码中引用
+    messageTitle: { type: Object, of: String, required: true }, // 消息标题,使用嵌套对象支持多语言
+    messageContent: { type: Object, of: String, required: true }, // 消息内容,使用嵌套对象支持多语言
+}, {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt 字段
+});
+// 为模板名称字段创建索引以提高查询效率
+MessageTemplateSchema.index({ templateName: 1 });
+exports.MessageTemplate = mongoose_1.default.model("MessageTemplate", MessageTemplateSchema);

+ 67 - 0
oms/dist/src/models/userModel.js

@@ -0,0 +1,67 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.User = void 0;
+// oms/src/models/userModel.ts
+const mongoose_1 = __importStar(require("mongoose"));
+// 定义 User Schema
+const UserSchema = new mongoose_1.Schema({
+    uid: { type: String, required: true, unique: true }, // uid 字段,唯一且必需, 对于project_id=1, 对应原始日志的uid字段,对于project_id=6, 对应原始日志的user_id
+    project: { type: Number, index: true }, // 来自原始日志的project_id字段,多个事件都有上报,属于公共属性, 填色android应用project_id=1, ios版本project_id=6
+    network: { type: String, index: true }, // 来自原始visit事件日志的 network 字段, 可能为null
+    campaign: { type: String, index: true }, // 来自原始visit事件日志的 campaign 字段, 可能为null
+    adgroup: { type: String, index: true }, // 来自原始visit事件日志的 adgroup 字段, 可能为null
+    creative: { type: String, index: true }, // 来自原始visit事件日志的 creative 字段, 可能为null
+    prod: { type: String, index: true }, // 来自原始日志的prod字段,多个事件都有上报, "number"表示填色应用, "jigsaw"表示拼图游戏等
+    libraryName: { type: String, enum: ["ios", "android"], index: true }, // 来自原始日志的library_name字段
+    cc: { type: String, index: true }, // 来自原始日志的cc字段,表示国家代码
+    lang: { type: String, index: true }, // 来自原始日志的lang字段,表示语言,目前没有上报,全是null,以后app更新会加上
+    manufacturer: { type: String }, // 来自原始日志的manufacturer字段,生产厂商
+    deviceModel: { type: String, index: true }, // 来自原始日志的model字段,表示设备型号
+    deviceInfo: { type: String }, // 来自原始日志的device字段,表示设备机型代号
+    hardware: { type: String }, // 来自原始日志的hardware字段,硬件的型号吧
+    deviceMem: { type: Number }, // 来自原始日志的deviceMem字段,表示机身内存,单位MB
+    apiLevel: { type: Number, index: true }, // 来自原始日志的android_api字段,表示andriod api级别,只对android版本有用
+    versionName: { type: String, index: true }, // 来自原始日志的version_name字段, 如果是ios填色应用(project_id=6)则对应的是library_version字段
+    versionCode: { type: Number, index: true }, // 来自原始日志的version_code 字段
+    fmToken: { type: String }, // 来自原始firebase_message_token日志的token字段,解析到firebase_message_token日志后更新
+    tags: { type: [String], index: true }, // <--- 关键修改:现在是字符串数组类型
+    firstLoginAt: { type: Date, index: true }, // 来自原始日志,表示首次登陆事件,考虑到前面的日志的丢失,可以通过visit事件日志的days天数字段来倒推
+    lastActiveAt: { type: Date, index: true }, // 来自原始日志的t字段或者create_at字段,表示上次活跃时间,结合days(如果有的话)可以倒推firstLoginAt
+}, {
+    timestamps: true, // 启用时间戳,自动维护 createdAt 和 updatedAt
+});
+// 创建并导出 User Model
+exports.User = mongoose_1.default.model("User", UserSchema);

+ 66 - 0
oms/dist/src/models/userPreferenceModel.js

@@ -0,0 +1,66 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+// oms/src/models/userPreferenceModel.ts
+const mongoose_1 = __importStar(require("mongoose"));
+// 定义 UserPreference Schema
+const UserPreferenceSchema = new mongoose_1.Schema({
+    uid: {
+        type: String,
+        required: true,
+        // uid 需要索引,因为它与 tag 构成复合唯一索引
+        index: true,
+    },
+    tag: {
+        type: String,
+        required: true,
+        // tag 需要索引,因为它与 uid 构成复合唯一索引
+        index: true,
+    },
+    count: {
+        type: Number,
+        required: true,
+        default: 0, // 默认值为0
+        min: 0, // 确保使用次数不为负
+    },
+}, {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt 字段
+});
+// 为 uid 和 tag 字段创建复合唯一索引
+// 这确保了每个 uid 和 tag 的组合都是唯一的,符合 "uid 和 tag 构成唯一索引" 的要求
+UserPreferenceSchema.index({ uid: 1, tag: 1 }, { unique: true });
+// 创建并导出 UserPreference Model
+const UserPreference = mongoose_1.default.model("UserPreference", UserPreferenceSchema);
+exports.default = UserPreference;

+ 65 - 0
oms/dist/src/routes/apiRoutes.js

@@ -0,0 +1,65 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const express_1 = require("express");
+const userController_1 = __importDefault(require("../controllers/userController"));
+const artController_1 = __importDefault(require("../controllers/artController"));
+const doneRateController_1 = __importDefault(require("../controllers/doneRateController"));
+const messageTemplateController_1 = __importDefault(require("../controllers/messageTemplateController"));
+const messageActivityController_1 = __importDefault(require("../controllers/messageActivityController")); // 新增:导入消息活动控制器
+const messageRecordController_1 = __importDefault(require("../controllers/messageRecordController")); // 新增:导入消息记录控制器
+const userTargetingController_1 = __importDefault(require("../controllers/userTargetingController"));
+const adminController_1 = __importDefault(require("../controllers/adminController"));
+const authMiddleware_1 = require("../middleware/authMiddleware");
+const router = (0, express_1.Router)();
+// 公共的管理员认证路由 (无需认证)
+router.post("/admin/register", adminController_1.default.register);
+router.post("/admin/login", adminController_1.default.login);
+router.post("/users/send-message", userController_1.default.sendDirectMessage); // 点对点
+// 应用认证中间件,保护所有下面的路由
+router.use(authMiddleware_1.authMiddleware);
+// User routes
+router.post("/users", userController_1.default.createUser);
+// Updated to the paginated user list interface
+router.get("/users", userController_1.default.getPaginatedUsers); // GET /api/users?page=1&limit=10&project=1
+router.get("/users/:uid", userController_1.default.getUserByUid);
+router.put("/users/:uid", userController_1.default.updateUser);
+router.delete("/users/:uid", userController_1.default.deleteUser);
+// 新增:发送消息接口
+router.post("/users/send-bulk-message", userController_1.default.sendBulkMessage); // 群发
+router.get("/arts", artController_1.default.getArts); // 获取作品列表 (支持分页、筛选、排序)
+router.get("/arts/:id", artController_1.default.getArtById); // 获取单个作品
+router.put("/arts/:id", artController_1.default.updateArt); // 更新作品信息
+// 完成率 DoneRate 路由 (只读)
+router.get("/done-rates/artwork/:resId", doneRateController_1.default.getDoneRatesByArtworkId); // 按作品 ID 获取历史完成率
+router.get("/done-rates/date/:date", doneRateController_1.default.getDoneRatesByDate); // 按日期获取所有作品完成率
+// 消息模板路由
+router.post("/message-template", messageTemplateController_1.default.createTemplate);
+router.get("/message-template", messageTemplateController_1.default.getAllTemplates);
+router.get("/message-template/:templateName", messageTemplateController_1.default.getTemplateByName);
+router.put("/message-template/:templateName", messageTemplateController_1.default.updateTemplate);
+router.delete("/message-template/:templateName", messageTemplateController_1.default.deleteTemplate);
+// 新增:消息活动路由
+router.post("/message-activity", messageActivityController_1.default.createActivity);
+router.get("/message-activities", messageActivityController_1.default.getActivities);
+router.get("/message-activity/:id", messageActivityController_1.default.getActivityById);
+router.put("/message-activity/:id", messageActivityController_1.default.updateActivity);
+router.delete("/message-activity/:id", messageActivityController_1.default.deleteActivity);
+router.put("/message-activity/:id/status", messageActivityController_1.default.updateActivityStatus);
+// 新增:用户筛选相关路由
+router.post("/users/count", userTargetingController_1.default.countTargetUsers);
+// 新增:消息记录路由
+router.post("/message-record", messageRecordController_1.default.createRecord);
+router.get("/message-records", messageRecordController_1.default.getPaginatedRecords); // 新增分页接口
+router.get("/message-records/user/:uid", messageRecordController_1.default.getRecordsByUid);
+router.get("/message-records/activity/:activityId", messageRecordController_1.default.getRecordsByActivityId);
+router.get("/message-record/:id", messageRecordController_1.default.getRecordById);
+router.put("/message-record/:id", messageRecordController_1.default.updateRecord);
+// 管理员路由
+router.get("/admin", adminController_1.default.getAdmins);
+router.get("/admin/:id", adminController_1.default.getAdminById);
+router.put("/admin/:id", adminController_1.default.updateAdmin);
+router.delete("/admin/:id", adminController_1.default.deleteAdmin);
+exports.default = router;

+ 459 - 0
oms/dist/src/scripts/ingestHistoricalData.js

@@ -0,0 +1,459 @@
+"use strict";
+// oms/scripts/ingestHistoricalData.ts
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+// Load environment variables for database credentials (MONGO_URI, CLICKHOUSE_*)
+// Log file directory is now passed as a function argument.
+const dotenv = __importStar(require("dotenv"));
+dotenv.config();
+const mongoose_1 = __importDefault(require("mongoose")); // Mongoose for OMS MongoDB models
+const dayjs_1 = __importDefault(require("dayjs")); // For date manipulation
+const duration_1 = __importDefault(require("dayjs/plugin/duration")); // dayjs plugin for duration
+const uuid_1 = require("uuid"); // For generating unique IDs where needed
+const userModel_1 = require("../models/userModel"); // OMS User Model
+const clickhouseService_1 = require("../services/clickhouseService"); // OMS ClickHouse Service
+// Node.js built-in modules for file processing
+const fs = __importStar(require("fs"));
+const path = __importStar(require("path"));
+const zlib = __importStar(require("zlib"));
+const readline = __importStar(require("readline"));
+dayjs_1.default.extend(duration_1.default);
+// --- Persistent Configuration (can still come from .env) ---
+const OMS_MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+const CLICKHOUSE_HOST = process.env.CLICKHOUSE_HOST || "http://localhost:8123";
+const CLICKHOUSE_DATABASE = process.env.CLICKHOUSE_DATABASE || "omsdb"; // Aligned with docker-compose.yml
+const CLICKHOUSE_USER = process.env.CLICKHOUSE_USER || "ckuser";
+const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD || "ckpassword";
+const CLICKHOUSE_EVENTS_TABLE = "events"; // Aligned with clickhouseService.ts table name
+// --- Batching Configuration ---
+const CLICKHOUSE_BATCH_SIZE = 5000; // ClickHouse 批量插入大小
+const MONGO_BATCH_SIZE = 1000; // MongoDB 批量写入大小
+// List of event types to process
+const ALLOWED_EVENT_TYPES = ["visit", "show_deeplink_dialog", "share", "save", "revenue", "rate", "favorite", "color_tip", "color_start", "color_done", "color_data", "ad_color_tip", "ad_color_float"];
+// Define an array of valid IUser keys for copying from event log to User model
+const USER_FIELDS_TO_UPDATE = [
+    "network",
+    "campaign",
+    "adgroup",
+    "creative",
+    "prod",
+    "libraryName", // maps to library_name
+    "cc",
+    "lang",
+    "manufacturer",
+    "deviceModel", // maps to model
+    "deviceInfo", // maps to device
+    "hardware",
+    "deviceMem",
+    "apiLevel", // maps to android_api
+    "versionName", // maps to version_name or library_version
+    "versionCode", // maps to version_code
+    "fmToken", // map to token
+    "project", // Add project field for updating, map to project_id
+];
+// --- Initialize Services ---
+let clickhouseService;
+async function initializeServices() {
+    try {
+        // Connect to OMS MongoDB (using Mongoose)
+        await mongoose_1.default.connect(OMS_MONGO_URI);
+        console.log(`Connected to OMS MongoDB: ${OMS_MONGO_URI}`);
+        // Initialize ClickHouse service with credentials
+        clickhouseService = new clickhouseService_1.ClickhouseService(CLICKHOUSE_HOST, CLICKHOUSE_DATABASE, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD);
+        // Ensure ClickHouse table exists
+        await clickhouseService.ensureTable(CLICKHOUSE_EVENTS_TABLE);
+        console.log(`ClickHouse Service initialized for ${CLICKHOUSE_DATABASE} at ${CLICKHOUSE_HOST}`);
+    }
+    catch (error) {
+        console.error("Failed to initialize services:", error);
+        process.exit(1); // Exit if essential services cannot connect
+    }
+}
+// --- Helper function to flush ClickHouse buffer ---
+async function flushClickHouseBuffer(buffer, tableName, processedCount) {
+    if (buffer.length === 0)
+        return;
+    try {
+        // clickhouseService.insertEvent now accepts IEventLog | IEventLog[]
+        await clickhouseService.insertEvent(tableName, buffer);
+        console.log(`Flushed ${buffer.length} events to ClickHouse. Total processed: ${processedCount}`);
+        buffer.length = 0; // Clear buffer
+    }
+    catch (error) {
+        console.error(`Error flushing ClickHouse buffer:`, error);
+        // Depending on error handling strategy, might rethrow or log and continue
+    }
+}
+// --- Historical Data Ingestion Logic ---
+// Now accepts only the log files directory as argument
+async function ingestHistoricalData(logFilesDir) {
+    const overallStartTime = (0, dayjs_1.default)();
+    console.log(`Starting historical ingestion from local files in: ${logFilesDir}`);
+    let totalProcessedEvents = 0;
+    let totalUpsertedUsers = 0;
+    // Buffers for batching
+    const clickhouseEventsBuffer = [];
+    const mongoUserWriteOperations = []; // Array of Mongoose bulkWrite operations
+    // Get all files from the directory and filter for .log.gz files
+    let allFiles = [];
+    try {
+        allFiles = fs
+            .readdirSync(logFilesDir)
+            .filter((file) => file.endsWith(".log.gz"))
+            .sort(); // Sort files to ensure chronological processing
+    }
+    catch (readDirError) {
+        console.error(`Error reading directory '${logFilesDir}':`, readDirError);
+        // If the directory can't be read, we can't proceed.
+        return;
+    }
+    const totalFiles = allFiles.length;
+    if (totalFiles === 0) {
+        console.log("No .log.gz files found in the specified directory. Exiting.");
+        return;
+    }
+    console.log(`Found ${totalFiles} .log.gz files to process.`);
+    for (let i = 0; i < totalFiles; i++) {
+        const expectedFilename = allFiles[i];
+        const filePath = path.join(logFilesDir, expectedFilename);
+        const fileStartTime = (0, dayjs_1.default)();
+        console.log(`\n--- Processing file ${i + 1}/${totalFiles}: ${expectedFilename} ---`);
+        let processedEventsCount = 0;
+        let upsertedUsersCount = 0;
+        const fileStream = fs.createReadStream(filePath);
+        const gunzip = zlib.createGunzip();
+        const rl = readline.createInterface({
+            input: fileStream.pipe(gunzip),
+            crlfDelay: Infinity, // Handle Windows/Unix line endings
+        });
+        try {
+            // Cache for firstLoginAt lookup for the current batch
+            const firstLoginAtCache = new Map();
+            const currentBatchUids = new Set();
+            const eventsInCurrentBatch = []; // Temporary store for raw eventLog in a batch
+            for await (const line of rl) {
+                if (!line.trim())
+                    continue; // Skip empty lines
+                let eventLog;
+                try {
+                    eventLog = JSON.parse(line);
+                }
+                catch (parseError) {
+                    console.error(`Error parsing JSON from line in '${expectedFilename}': ${line}. Error: ${parseError}`);
+                    continue; // Skip malformed JSON lines
+                }
+                // Filter by project_id
+                const projectId = eventLog.project_id;
+                if (projectId !== 1 && projectId !== 6) {
+                    continue; // Skip if not project 1 or 6
+                }
+                // Determine event type field name based on project_id
+                const eventType = projectId === 1 ? eventLog.type : eventLog.name;
+                // Filter by allowed event types
+                if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
+                    continue; // Skip if not an allowed event type
+                }
+                // Skip events with invalid duration
+                if (eventLog.duration > 100000 || eventLog.duration < 0) {
+                    console.warn(`Skipping event with invalid duration: ${eventLog.duration}. Event: ${JSON.stringify(eventLog)}`);
+                    continue;
+                }
+                const uid = projectId === 1 ? eventLog.uid : eventLog.user_id;
+                if (!uid) {
+                    console.warn(`Skipping event with missing UID in '${expectedFilename}': ${JSON.stringify(eventLog)}`);
+                    continue;
+                }
+                // --- Accumulate for User Data (MongoDB) Batch ---
+                currentBatchUids.add(uid);
+                eventsInCurrentBatch.push(eventLog); // Store raw eventLog temporarily
+                // Process a batch when Mongo batch size is reached
+                if (eventsInCurrentBatch.length >= MONGO_BATCH_SIZE) {
+                    // 1. Fetch existing firstLoginAt for all UIDs in the current batch
+                    const existingUsers = await userModel_1.User.find({ uid: { $in: Array.from(currentBatchUids) } }, { uid: 1, firstLoginAt: 1 });
+                    existingUsers.forEach((user) => {
+                        firstLoginAtCache.set(user.uid, user.firstLoginAt);
+                    });
+                    for (const batchedEventLog of eventsInCurrentBatch) {
+                        const batchUid = batchedEventLog.project_id === 1 ? batchedEventLog.uid : batchedEventLog.user_id;
+                        const batchProjectId = batchedEventLog.project_id;
+                        let batchLastActiveAtDateObj; // Declare here
+                        if (batchedEventLog.t) {
+                            batchLastActiveAtDateObj = (0, dayjs_1.default)(batchedEventLog.t).toDate();
+                        }
+                        else if (batchedEventLog.create_at) {
+                            batchLastActiveAtDateObj = (0, dayjs_1.default)(batchedEventLog.create_at).toDate();
+                        }
+                        else {
+                            batchLastActiveAtDateObj = new Date();
+                        }
+                        let batchFirstLoginAt;
+                        if (batchedEventLog.days !== undefined && batchedEventLog.days !== null) {
+                            // Changed to use batchLastActiveAtDateObj directly
+                            batchFirstLoginAt = (0, dayjs_1.default)(batchLastActiveAtDateObj).subtract(batchedEventLog.days, "day").toDate();
+                        }
+                        const batchUserUpdate = { project: batchProjectId };
+                        batchUserUpdate.lastActiveAt = batchLastActiveAtDateObj;
+                        if (batchFirstLoginAt) {
+                            const cachedFirstLoginAt = firstLoginAtCache.get(batchUid);
+                            if (!cachedFirstLoginAt || !cachedFirstLoginAt.getTime() || (0, dayjs_1.default)(batchFirstLoginAt).isBefore(cachedFirstLoginAt)) {
+                                batchUserUpdate.firstLoginAt = batchFirstLoginAt;
+                            }
+                        }
+                        for (const field of USER_FIELDS_TO_UPDATE) {
+                            let sourceFieldName;
+                            if (field === "libraryName")
+                                sourceFieldName = "library_name";
+                            else if (field === "deviceModel")
+                                sourceFieldName = "model";
+                            else if (field === "deviceInfo")
+                                sourceFieldName = "device";
+                            else if (field === "apiLevel")
+                                sourceFieldName = "android_api";
+                            else if (field === "versionName")
+                                sourceFieldName = batchProjectId === 1 ? "version_name" : "library_version";
+                            else if (field === "versionCode")
+                                sourceFieldName = "version_code";
+                            else if (field === "deviceMem")
+                                sourceFieldName = "deviceMem";
+                            else if (field === "project")
+                                continue;
+                            else if (field === "fmToken")
+                                sourceFieldName = "token";
+                            else
+                                sourceFieldName = field;
+                            if (field === "uid")
+                                continue;
+                            if (sourceFieldName && batchedEventLog[sourceFieldName] !== undefined && batchedEventLog[sourceFieldName] !== null) {
+                                if (field === "deviceMem" && typeof batchedEventLog[sourceFieldName] === "number") {
+                                    batchUserUpdate.deviceMem = batchedEventLog[sourceFieldName];
+                                }
+                                else {
+                                    batchUserUpdate[field] = batchedEventLog[sourceFieldName];
+                                }
+                            }
+                        }
+                        mongoUserWriteOperations.push({
+                            updateOne: {
+                                filter: { uid: batchUid },
+                                update: {
+                                    $set: batchUserUpdate,
+                                    $setOnInsert: { uid: batchUid, createdAt: new Date() },
+                                },
+                                upsert: true,
+                            },
+                        });
+                    }
+                    if (mongoUserWriteOperations.length > 0) {
+                        try {
+                            const bulkResult = await userModel_1.User.bulkWrite(mongoUserWriteOperations);
+                            upsertedUsersCount += (bulkResult.upsertedCount || 0) + (bulkResult.modifiedCount || 0);
+                            console.log(`MongoDB batch written. Upserted/Modified: ${bulkResult.upsertedCount + bulkResult.modifiedCount}`);
+                        }
+                        catch (bulkError) {
+                            console.error(`Error in MongoDB bulkWrite for file '${expectedFilename}':`, bulkError);
+                        }
+                        mongoUserWriteOperations.length = 0; // Clear buffer
+                    }
+                    eventsInCurrentBatch.length = 0; // Clear raw events buffer
+                    currentBatchUids.clear(); // Clear UIDs for next batch
+                    firstLoginAtCache.clear(); // Clear cache for next batch
+                }
+                // --- Prepare Event Data for ClickHouse Batch ---
+                // Redeclare lastActiveAtDateObj for ClickHouse event processing if not already in scope.
+                // It's safer to recalculate or ensure it's in scope, here we use `let` to declare it
+                // in the scope of the `for await` loop.
+                let lastActiveAtDateObj;
+                if (eventLog.t) {
+                    lastActiveAtDateObj = (0, dayjs_1.default)(eventLog.t).toDate();
+                }
+                else if (eventLog.create_at) {
+                    lastActiveAtDateObj = (0, dayjs_1.default)(eventLog.create_at).toDate();
+                }
+                else {
+                    lastActiveAtDateObj = new Date();
+                }
+                const clickhouseEvent = {
+                    log_id: eventLog._id ? eventLog._id.toString() : (0, uuid_1.v4)(),
+                    uid: uid,
+                    project: projectId,
+                    os: eventLog.library_name || null,
+                    version: projectId === 1 ? eventLog.version_name : eventLog.library_version || null,
+                    event: eventType,
+                    time: lastActiveAtDateObj, // Use the explicitly formatted string for ClickHouse
+                    res: projectId === 1 ? eventLog.res : eventLog.sku_id || null,
+                    from: projectId === 1 ? eventLog.from : eventLog.tab_source || null,
+                    position: projectId === 1 ? eventLog.position : eventLog.click_position || null,
+                    duration: eventLog.duration || null, // Handles Nullable(UInt32) if present
+                    ad_type: eventLog.ad_type || null,
+                    ad_src: eventLog.ad_src || null,
+                    revenue: projectId === 1 ? eventLog.rev : eventLog.ad_revenue || null,
+                    cc: eventLog.cc || null,
+                    raw_json_data: JSON.stringify(eventLog),
+                };
+                clickhouseEventsBuffer.push(clickhouseEvent);
+                processedEventsCount++;
+                // Flush ClickHouse buffer if batch size reached
+                if (clickhouseEventsBuffer.length >= CLICKHOUSE_BATCH_SIZE) {
+                    await flushClickHouseBuffer(clickhouseEventsBuffer, CLICKHOUSE_EVENTS_TABLE, processedEventsCount);
+                }
+            } // End of for await (const line of rl) loop
+            // --- Flush any remaining buffers after file processing ---
+            // Flush remaining MongoDB operations
+            // Process remaining user data (MongoDB) that didn't form a full batch
+            if (eventsInCurrentBatch.length > 0) {
+                // If UIDs exist and cache is empty (meaning no full batch was processed), fetch existing users
+                if (currentBatchUids.size > 0 && firstLoginAtCache.size === 0) {
+                    const existingUsers = await userModel_1.User.find({ uid: { $in: Array.from(currentBatchUids) } }, { uid: 1, firstLoginAt: 1 });
+                    existingUsers.forEach((user) => {
+                        firstLoginAtCache.set(user.uid, user.firstLoginAt);
+                    });
+                }
+                for (const batchedEventLog of eventsInCurrentBatch) {
+                    const batchUid = batchedEventLog.project_id === 1 ? batchedEventLog.uid : batchedEventLog.user_id;
+                    const batchProjectId = batchedEventLog.project_id;
+                    let batchLastActiveAtDateObj;
+                    if (batchedEventLog.t) {
+                        batchLastActiveAtDateObj = (0, dayjs_1.default)(batchedEventLog.t).toDate();
+                    }
+                    else if (batchedEventLog.create_at) {
+                        batchLastActiveAtDateObj = (0, dayjs_1.default)(batchedEventLog.create_at).toDate();
+                    }
+                    else {
+                        batchLastActiveAtDateObj = new Date();
+                    }
+                    let batchFirstLoginAt;
+                    if (batchedEventLog.days !== undefined && batchedEventLog.days !== null) {
+                        // Changed to use batchLastActiveAtDateObj directly
+                        batchFirstLoginAt = (0, dayjs_1.default)(batchLastActiveAtDateObj).subtract(batchedEventLog.days, "day").toDate();
+                    }
+                    const batchUserUpdate = { project: batchProjectId };
+                    batchUserUpdate.lastActiveAt = batchLastActiveAtDateObj;
+                    if (batchFirstLoginAt) {
+                        const cachedFirstLoginAt = firstLoginAtCache.get(batchUid);
+                        // Ensure cachedFirstLoginAt is a valid Date before comparison
+                        if (!cachedFirstLoginAt || !cachedFirstLoginAt.getTime() || (0, dayjs_1.default)(batchFirstLoginAt).isBefore(cachedFirstLoginAt)) {
+                            batchUserUpdate.firstLoginAt = batchFirstLoginAt;
+                        }
+                    }
+                    for (const field of USER_FIELDS_TO_UPDATE) {
+                        let sourceFieldName;
+                        if (field === "libraryName")
+                            sourceFieldName = "library_name";
+                        else if (field === "deviceModel")
+                            sourceFieldName = "model";
+                        else if (field === "deviceInfo")
+                            sourceFieldName = "device";
+                        else if (field === "apiLevel")
+                            sourceFieldName = "android_api";
+                        else if (field === "versionName")
+                            sourceFieldName = batchProjectId === 1 ? "version_name" : "library_version";
+                        else if (field === "versionCode")
+                            sourceFieldName = "version_code";
+                        else if (field === "deviceMem")
+                            sourceFieldName = "deviceMem";
+                        else if (field === "project")
+                            continue;
+                        else if (field === "fmToken")
+                            sourceFieldName = "token";
+                        else
+                            sourceFieldName = field;
+                        if (field === "uid")
+                            continue;
+                        if (sourceFieldName && batchedEventLog[sourceFieldName] !== undefined && batchedEventLog[sourceFieldName] !== null) {
+                            if (field === "deviceMem" && typeof batchedEventLog[sourceFieldName] === "number") {
+                                batchUserUpdate.deviceMem = batchedEventLog[sourceFieldName];
+                            }
+                            else {
+                                batchUserUpdate[field] = batchedEventLog[sourceFieldName];
+                            }
+                        }
+                    }
+                    mongoUserWriteOperations.push({
+                        updateOne: {
+                            filter: { uid: batchUid },
+                            update: {
+                                $set: batchUserUpdate,
+                                $setOnInsert: { uid: batchUid, createdAt: new Date() },
+                            },
+                            upsert: true,
+                        },
+                    });
+                }
+            }
+            if (mongoUserWriteOperations.length > 0) {
+                try {
+                    const bulkResult = await userModel_1.User.bulkWrite(mongoUserWriteOperations);
+                    upsertedUsersCount += (bulkResult.upsertedCount || 0) + (bulkResult.modifiedCount || 0);
+                    // console.log(`MongoDB final batch written. Upserted/Modified: ${bulkResult.upsertedCount + bulkResult.modifiedCount}`);
+                }
+                catch (bulkError) {
+                    console.error(`Error in final MongoDB bulkWrite for file '${expectedFilename}':`, bulkError);
+                }
+                mongoUserWriteOperations.length = 0; // Clear buffer
+            }
+            // Flush remaining ClickHouse events
+            await flushClickHouseBuffer(clickhouseEventsBuffer, CLICKHOUSE_EVENTS_TABLE, processedEventsCount);
+            totalProcessedEvents += processedEventsCount;
+            totalUpsertedUsers += upsertedUsersCount;
+            const fileDuration = dayjs_1.default.duration((0, dayjs_1.default)().diff(fileStartTime)).asSeconds();
+            console.log(`File '${expectedFilename}' processed: ${processedEventsCount} events, ${upsertedUsersCount} users upserted. Time taken: ${fileDuration.toFixed(2)} seconds.`);
+        }
+        catch (error) {
+            console.error(`Error processing file '${expectedFilename}':`, error);
+            // If an error occurs during file processing, we still want to move to the next file
+        }
+    } // End of for...of allFiles loop
+    const overallDuration = dayjs_1.default.duration((0, dayjs_1.default)().diff(overallStartTime)).asSeconds();
+    console.log(`\n--- Historical data ingestion complete ---`);
+    console.log(`Total processed events: ${totalProcessedEvents}`);
+    console.log(`Total upserted users: ${totalUpsertedUsers}`);
+    console.log(`Total time taken: ${overallDuration.toFixed(2)} seconds.`);
+}
+// --- Main execution ---
+async function main() {
+    await initializeServices(); // Initialize OMS DB and ClickHouse
+    // --- Hardcoded parameters for one-time ingestion ---
+    const logFilesDirectory = process.argv[2] || path.join(__dirname, "../../../../../logs/"); // Fallback directory
+    await ingestHistoricalData(logFilesDirectory);
+    // Ensure mongoose connection is properly closed only if it was connected
+    if (mongoose_1.default.connection.readyState === 1)
+        await mongoose_1.default.disconnect();
+    console.log("Database connections closed.");
+    process.exit(0);
+}
+main().catch(console.error);

+ 283 - 0
oms/dist/src/scripts/ingestHistoricalDataFromClog.js

@@ -0,0 +1,283 @@
+"use strict";
+// oms/scripts/ingestHistoricalData.ts
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+// Load environment variables from .env (or .env.ingest if specified in script command)
+const dotenv = __importStar(require("dotenv"));
+dotenv.config();
+const mongodb_1 = require("mongodb"); // Native MongoDB driver for external connection
+const mongoose_1 = __importDefault(require("mongoose")); // Mongoose for OMS MongoDB models
+const dayjs_1 = __importDefault(require("dayjs")); // For date manipulation
+const duration_1 = __importDefault(require("dayjs/plugin/duration")); // dayjs plugin for duration
+const isSameOrBefore_1 = __importDefault(require("dayjs/plugin/isSameOrBefore")); // 👈 NEW: dayjs plugin for isSameOrBefore
+const userModel_1 = require("../models/userModel"); // OMS User Model
+const clickhouseService_1 = require("../services/clickhouseService"); // OMS ClickHouse Service
+const uuid_1 = require("uuid");
+dayjs_1.default.extend(duration_1.default);
+dayjs_1.default.extend(isSameOrBefore_1.default); // Extend dayjs with the isSameOrBefore plugin
+// --- Configuration ---
+const CLOGS_MONGO_URI = process.env.CLOGS_MONGO_URI || "mongodb://localhost:27017/clogs";
+const CLOGS_DATABASE_NAME = process.env.CLOGS_DATABASE_NAME || "clogs"; // Database containing daily collections
+const OMS_MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb";
+const CLICKHOUSE_HOST = process.env.CLICKHOUSE_HOST || "http://localhost:8123";
+const CLICKHOUSE_DATABASE = process.env.CLICKHOUSE_DATABASE || "omsdb";
+const CLICKHOUSE_USER = process.env.CLICKHOUSE_USER || "ckuser";
+const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD || "ckpassword";
+const CLICKHOUSE_EVENTS_TABLE = "events";
+// Specify the date range for ingestion (inclusive)
+// Format: YYYYMMDD
+const START_DATE_STR = process.env.START_DATE || "20250820";
+const END_DATE_STR = process.env.END_DATE || "20250820";
+// List of event types to process
+const ALLOWED_EVENT_TYPES = [
+    "visit",
+    "show_deeplink_dialog",
+    "share",
+    "save",
+    "revenue",
+    "rate",
+    "favorite",
+    "color_tip",
+    "color_start",
+    "color_done",
+    "color_data",
+    "ad_color_tip",
+    "ad_color_float",
+];
+// Define an array of valid IUser keys for copying from event log to User model
+const USER_FIELDS_TO_UPDATE = [
+    "network",
+    "campaign",
+    "adgroup",
+    "creative",
+    "prod",
+    "libraryName", // maps to library_name
+    "cc",
+    "lang",
+    "manufacturer",
+    "deviceModel", // maps to model
+    "deviceInfo", // maps to device
+    "hardware",
+    "deviceMem",
+    "apiLevel", // maps to android_api
+    "versionName", // maps to version_name or library_version
+    "versionCode", // maps to version_code
+    "fmToken", // map to token
+    // tags and loginAt are handled specially
+    "project" // Add project field for updating, map to project_id
+];
+// --- Initialize Services ---
+let clogsClient;
+let clickhouseService;
+async function initializeServices() {
+    try {
+        // Connect to external clogs MongoDB
+        clogsClient = new mongodb_1.MongoClient(CLOGS_MONGO_URI);
+        await clogsClient.connect();
+        console.log(`Connected to CLOGS MongoDB: ${CLOGS_MONGO_URI}`);
+        // Connect to OMS MongoDB (using Mongoose)
+        await mongoose_1.default.connect(OMS_MONGO_URI);
+        console.log(`Connected to OMS MongoDB: ${OMS_MONGO_URI}`);
+        // Initialize ClickHouse service with credentials
+        clickhouseService = new clickhouseService_1.ClickhouseService(CLICKHOUSE_HOST, CLICKHOUSE_DATABASE, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD);
+        // Ensure ClickHouse table exists
+        await clickhouseService.ensureTable(CLICKHOUSE_EVENTS_TABLE);
+        console.log(`ClickHouse Service initialized for ${CLICKHOUSE_DATABASE} at ${CLICKHOUSE_HOST}`);
+    }
+    catch (error) {
+        console.error("Failed to initialize services:", error);
+        process.exit(1); // Exit if essential services cannot connect
+    }
+}
+// --- Historical Data Ingestion Logic ---
+async function ingestHistoricalData() {
+    console.log(`ingestHistoricalData from ${START_DATE_STR} to ${END_DATE_STR}`);
+    let currentDate = (0, dayjs_1.default)(START_DATE_STR, "YYYYMMDD");
+    const endDate = (0, dayjs_1.default)(END_DATE_STR, "YYYYMMDD");
+    while (currentDate.isSameOrBefore(endDate, "day")) {
+        const collectionName = currentDate.format("YYYYMMDD");
+        console.log(`\n--- Processing collection: ${collectionName} ---`);
+        const clogsDb = clogsClient.db(CLOGS_DATABASE_NAME);
+        const collections = await clogsDb
+            .listCollections({ name: collectionName })
+            .toArray();
+        const collectionExists = collections.length > 0;
+        if (!collectionExists) {
+            console.warn(`Collection '${collectionName}' does not exist. Skipping.`);
+            currentDate = currentDate.add(1, "day");
+            continue;
+        }
+        const collection = clogsDb.collection(collectionName);
+        let processedEventsCount = 0;
+        let upsertedUsersCount = 0;
+        try {
+            const cursor = collection.find({});
+            for await (const eventLog of cursor) {
+                // Filter by project_id
+                const projectId = eventLog.project_id;
+                if (projectId !== 1 && projectId !== 6) {
+                    continue; // Skip if not project 1 or 6
+                }
+                // Determine event type field name based on project_id
+                const eventType = projectId === 1 ? eventLog.type : eventLog.name;
+                // Filter by allowed event types
+                if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
+                    continue; // Skip if not an allowed event type
+                }
+                const uid = projectId === 1 ? eventLog.uid : eventLog.user_id;
+                if (!uid) {
+                    console.warn(`Skipping event with missing UID: ${JSON.stringify(eventLog)}`);
+                    continue;
+                }
+                // --- 1. Process User Data (MongoDB) ---
+                const lastActiveAtDateObj = eventLog.t
+                    ? (0, dayjs_1.default)(eventLog.t).toDate()
+                    : eventLog.create_at
+                        ? (0, dayjs_1.default)(eventLog.create_at).toDate()
+                        : new Date();
+                // Format lastActiveAt for ClickHouse specifically
+                // Explicitly format to 'YYYY-MM-DD HH:mm:ss' which ClickHouse DateTime type expects
+                const lastActiveAtClickHouse = (0, dayjs_1.default)(lastActiveAtDateObj).format("YYYY-MM-DD HH:mm:ss");
+                let firstLoginAt;
+                if (eventLog.days !== undefined &&
+                    eventLog.days !== null &&
+                    lastActiveAtDateObj) {
+                    // days 字段是天数,倒推 firstLoginAt
+                    firstLoginAt = (0, dayjs_1.default)(lastActiveAtDateObj)
+                        .subtract(eventLog.days, "day")
+                        .toDate();
+                }
+                // Initialize userUpdate without 'uid' here, as it's in the query filter
+                const userUpdate = { project: projectId }; // project is okay here
+                userUpdate.lastActiveAt = lastActiveAtDateObj; // Still use Date object for MongoDB
+                // Logic for firstLoginAt: only update if current event's calculated firstLoginAt is earlier or if it's new
+                if (firstLoginAt) {
+                    const existingUser = await userModel_1.User.findOne({ uid: uid }, { firstLoginAt: 1 });
+                    if (!existingUser ||
+                        !existingUser.firstLoginAt ||
+                        (0, dayjs_1.default)(firstLoginAt).isBefore(existingUser.firstLoginAt)) {
+                        userUpdate.firstLoginAt = firstLoginAt;
+                    }
+                }
+                // Copy other relevant user profile fields from event log
+                for (const field of USER_FIELDS_TO_UPDATE) {
+                    // Map source field names to target model names
+                    let sourceFieldName;
+                    if (field === 'libraryName')
+                        sourceFieldName = 'library_name';
+                    else if (field === 'deviceModel')
+                        sourceFieldName = 'model';
+                    else if (field === 'deviceInfo')
+                        sourceFieldName = 'device';
+                    else if (field === 'apiLevel')
+                        sourceFieldName = 'android_api';
+                    else if (field === 'versionName')
+                        sourceFieldName = projectId === 1 ? 'version_name' : 'library_version';
+                    else if (field === 'versionCode')
+                        sourceFieldName = 'version_code';
+                    else if (field === 'deviceMem')
+                        sourceFieldName = 'deviceMem';
+                    else if (field === 'project')
+                        continue; // project is already handled and should not be re-copied from loop
+                    else
+                        sourceFieldName = field; // For fields with consistent names
+                    // Skip uid and project as they are handled explicitly or are part of the query
+                    if (field === 'uid')
+                        continue;
+                    if (sourceFieldName && eventLog[sourceFieldName] !== undefined && eventLog[sourceFieldName] !== null) {
+                        userUpdate[field] = eventLog[sourceFieldName];
+                    }
+                }
+                // Perform upsert
+                // Use updateOne for more precise control and to avoid the findOneAndUpdate 'uid' conflict
+                const result = await userModel_1.User.updateOne({ uid: uid }, {
+                    $set: userUpdate,
+                    $setOnInsert: { uid: uid, createdAt: new Date() },
+                }, { upsert: true });
+                if (result.upsertedCount > 0 || result.modifiedCount > 0) {
+                    upsertedUsersCount++;
+                }
+                // --- 2. Process Event Data (ClickHouse) ---
+                const clickhouseEvent = {
+                    log_id: eventLog._id ? eventLog._id.toString() : (0, uuid_1.v4)(),
+                    uid: uid,
+                    project: projectId,
+                    os: eventLog.library_name || null,
+                    version: projectId === 1
+                        ? eventLog.version_name
+                        : eventLog.library_version || null,
+                    event: eventType,
+                    time: lastActiveAtClickHouse, // <--- Use the explicitly formatted string for ClickHouse
+                    res: projectId === 1 ? eventLog.res : eventLog.sku_id || null,
+                    from: projectId === 1 ? eventLog.from : eventLog.tab_source || null,
+                    position: projectId === 1
+                        ? eventLog.position
+                        : eventLog.click_position || null,
+                    duration: eventLog.duration || null, // Handles Nullable(UInt32) if present
+                    ad_type: eventLog.ad_type || null,
+                    ad_src: eventLog.ad_src || null,
+                    revenue: projectId === 1 ? eventLog.rev : eventLog.ad_revenue || null, // project_id=1 uses 'rev', project_id=6 uses 'ad_revenue'
+                    cc: eventLog.cc || null,
+                    raw_json_data: JSON.stringify(eventLog),
+                };
+                await clickhouseService.insertEvent(CLICKHOUSE_EVENTS_TABLE, clickhouseEvent);
+                processedEventsCount++;
+            }
+            console.log(`Processed ${processedEventsCount} events and upserted ${upsertedUsersCount} users for collection '${collectionName}'.`);
+        }
+        catch (error) {
+            console.error(`Error processing collection '${collectionName}':`, error);
+        }
+        currentDate = currentDate.add(1, "day");
+    }
+    console.log("\n--- Historical data ingestion complete ---");
+}
+// --- Main execution ---
+async function main() {
+    await initializeServices(); // Initialize OMS DB and ClickHouse
+    await ingestHistoricalData(); // Run historical ingestion
+    // Close connections gracefully for historical script
+    if (clogsClient)
+        await clogsClient.close();
+    // Ensure mongoose connection is properly closed only if it was connected
+    if (mongoose_1.default.connection.readyState === 1)
+        await mongoose_1.default.disconnect();
+    console.log("Database connections closed.");
+    process.exit(0);
+}
+main().catch(console.error);

+ 178 - 0
oms/dist/src/scripts/migrate-done-rates.js

@@ -0,0 +1,178 @@
+"use strict";
+// oms/scripts/migrate-done-rates.ts
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const mongoose_1 = __importStar(require("mongoose"));
+const doneRateModel_1 = __importDefault(require("../models/doneRateModel")); // 导入本地 DoneRate 模型
+const dayjs_1 = __importDefault(require("dayjs"));
+const customParseFormat_1 = __importDefault(require("dayjs/plugin/customParseFormat"));
+dayjs_1.default.extend(customParseFormat_1.default);
+// --- 数据库配置 ---
+// 本地 OMS 数据库的连接字符串
+const LOCAL_MONGO_URI = "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+// 远程旧 CLOGS 数据库的连接字符串
+const REMOTE_CLOGS_MONGO_URI = "mongodb://clogs:clogs123.%23%23@localhost:27017/clogs";
+const oldDoneRateSchema = new mongoose_1.Schema({
+    _id: String,
+    collectionName: String,
+    res: mongoose_1.Schema.Types.ObjectId,
+    startCount: Number,
+    doneCount: Number,
+    completionRate: Number,
+}, {
+    collection: "done_rate", // 指定旧的表名
+    versionKey: false,
+    _id: false, // _id 已经由旧数据提供,不自动生成
+});
+let localConn = null;
+let remoteConn = null;
+// 定义每次处理的数据量
+const BATCH_SIZE = 5000;
+/**
+ * 迁移函数,执行数据割接
+ */
+async function migrateDoneRates() {
+    console.log("[Migration] Starting data migration from clogs.done_rate to oms.doneRates...");
+    try {
+        // 1. 建立本地和远程数据库连接
+        localConn = await mongoose_1.default.createConnection(LOCAL_MONGO_URI);
+        console.log("[Migration] Connected to local OMS database.");
+        remoteConn = await mongoose_1.default.createConnection(REMOTE_CLOGS_MONGO_URI);
+        const OldDoneRate = remoteConn.model("OldDoneRate", oldDoneRateSchema);
+        console.log("[Migration] Connected to remote CLOGS database.");
+        // 2. 使用游标分批处理数据
+        console.log(`[Migration] Processing records in batches of ${BATCH_SIZE}...`);
+        let processedCount = 0;
+        let successfulInserts = 0;
+        let batch = [];
+        // 使用游标查询,不会一次性加载所有数据到内存
+        const cursor = OldDoneRate.find({}).lean().cursor();
+        // 逐条处理游标中的数据
+        for await (const doc of cursor) {
+            // 格式化数据以匹配本地模型
+            const newRecord = {
+                date: doc.collectionName,
+                res: doc.res,
+                startCount: doc.startCount,
+                doneCount: doc.doneCount,
+                completionRate: doc.completionRate,
+            };
+            batch.push(newRecord);
+            // 如果达到批次大小,则执行批量插入
+            if (batch.length >= BATCH_SIZE) {
+                try {
+                    const result = await localConn.model("DoneRate", doneRateModel_1.default.schema).insertMany(batch, { ordered: false });
+                    successfulInserts += result.length;
+                }
+                catch (error) {
+                    if (error.code === 11000) {
+                        console.warn("[Migration] Duplicate key error in batch. Continuing...");
+                        // 如果存在重复项,则尝试逐条插入以找出成功的记录
+                        for (const item of batch) {
+                            try {
+                                await localConn.model("DoneRate", doneRateModel_1.default.schema).create(item);
+                                successfulInserts++;
+                            }
+                            catch (e) {
+                                if (e.code !== 11000) {
+                                    console.error(`[Migration] Failed to insert record ${JSON.stringify(item)}:`, e.message);
+                                }
+                            }
+                        }
+                    }
+                    else {
+                        console.error("[Migration] An error occurred during a batch insertion:", error.message);
+                    }
+                }
+                processedCount += batch.length;
+                console.log(`[Migration] Processed ${processedCount} records. Total successfully inserted: ${successfulInserts}`);
+                // 清空批次,准备下一轮
+                batch = [];
+            }
+        }
+        // 处理最后一个未满的批次
+        if (batch.length > 0) {
+            try {
+                const result = await localConn.model("DoneRate", doneRateModel_1.default.schema).insertMany(batch, { ordered: false });
+                successfulInserts += result.length;
+            }
+            catch (error) {
+                if (error.code === 11000) {
+                    console.warn("[Migration] Duplicate key error in final batch. Continuing...");
+                    for (const item of batch) {
+                        try {
+                            await localConn.model("DoneRate", doneRateModel_1.default.schema).create(item);
+                            successfulInserts++;
+                        }
+                        catch (e) {
+                            if (e.code !== 11000) {
+                                console.error(`[Migration] Failed to insert record ${JSON.stringify(item)}:`, e.message);
+                            }
+                        }
+                    }
+                }
+                else {
+                    console.error("[Migration] An error occurred during the final batch insertion:", error.message);
+                }
+            }
+            processedCount += batch.length;
+        }
+        console.log(`[Migration] All records processed. Total processed: ${processedCount}. Total successfully inserted: ${successfulInserts}`);
+        console.log("[Migration] Data migration completed successfully!");
+    }
+    catch (error) {
+        console.error("[Migration] A critical error occurred. Script will exit:", error);
+        throw error;
+    }
+    finally {
+        // 确保关闭所有连接
+        if (localConn) {
+            await localConn.close();
+            console.log("[Migration] Local connection closed.");
+        }
+        if (remoteConn) {
+            await remoteConn.close();
+            console.log("[Migration] Remote connection closed.");
+        }
+    }
+}
+// 运行迁移脚本
+migrateDoneRates().catch((err) => {
+    console.error("[Migration] Script failed to run:", err);
+    process.exit(1); // 退出并返回错误码
+});

+ 133 - 0
oms/dist/src/scripts/send-fcm-script.js

@@ -0,0 +1,133 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const dotenv = __importStar(require("dotenv"));
+dotenv.config();
+const mongoose_1 = __importDefault(require("mongoose"));
+const userController_1 = __importDefault(require("../controllers/userController")); // 确保路径正确
+// --- 环境配置 ---
+const OMS_MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+const TARGET_UID = "my-samsumg";
+const SEND_INTERVAL_MS = 10 * 60 * 1000; // 10 分钟
+// --- 消息内容 ---
+const MESSAGE_CONTENT = {
+    content: "🎉 Every tap births a new universe. 🎉",
+    image: "https://color.jccytech.cn/thumbs/v2/page/640/68b7f2bfe5ced54b83ecf8d3.png",
+    bigger: true,
+};
+// 消息序号,从 0 开始
+let messageCounter = 7;
+let intervalId = null;
+/**
+ * 模拟一个简化的 Express Response 对象,用于传递给控制器方法。
+ * 它会捕获状态码和 JSON 响应,并打印到控制台。
+ */
+class MockResponse {
+    constructor() {
+        this.statusCode = 200;
+    }
+    status(code) {
+        this.statusCode = code;
+        return this;
+    }
+    json(data) {
+        console.log(`[响应] 状态码: ${this.statusCode}`);
+        console.log("[响应] JSON 数据:", JSON.stringify(data, null, 2));
+        return this;
+    }
+}
+/**
+ * 异步函数,直接调用 userController.sendDirectMessage 方法。
+ * 每次调用都会递增消息序号,并构建相应的请求体。
+ */
+async function sendFCMMessage() {
+    messageCounter++;
+    const dynamicTitle = `${messageCounter}. ❤️ Time to Relax!`;
+    // 模拟一个 Express Request 对象
+    const mockRequest = {
+        body: {
+            uid: TARGET_UID,
+            title: dynamicTitle,
+            ...MESSAGE_CONTENT,
+        },
+    }; // 类型断言为 Request
+    const mockResponse = new MockResponse(); // 类型断言为 Response
+    console.log(`\n[${new Date().toLocaleString()}] 正在调用后端方法发送第 ${messageCounter} 条消息给 ${TARGET_UID}...`);
+    try {
+        await userController_1.default.sendDirectMessage(mockRequest, mockResponse);
+    }
+    catch (error) {
+        console.error(`[${new Date().toLocaleString()}] 调用 sendDirectMessage 方法失败:`, error);
+    }
+}
+/**
+ * 初始化并启动脚本。
+ * 连接到 MongoDB,然后设置定时器。
+ */
+async function startScript() {
+    try {
+        await mongoose_1.default.connect(OMS_MONGO_URI);
+        console.log(`[MongoDB] 已连接到数据库: ${OMS_MONGO_URI}`);
+        // 立即运行一次
+        await sendFCMMessage();
+        // 设置定时器,每 10 分钟运行一次
+        intervalId = setInterval(sendFCMMessage, SEND_INTERVAL_MS);
+        console.log(`\n脚本已启动。将每隔 10 分钟向用户 ${TARGET_UID} 发送一次 FCM 消息。`);
+        console.log(`按下 Ctrl + C 停止脚本。`);
+    }
+    catch (error) {
+        console.error("[初始化失败] 无法连接到 MongoDB 或启动脚本:", error);
+        process.exit(1);
+    }
+}
+// --- 优雅退出处理 ---
+async function gracefulShutdown() {
+    console.log("\n[退出] 脚本正在关闭...");
+    if (intervalId) {
+        clearInterval(intervalId);
+    }
+    if (mongoose_1.default.connection.readyState === 1) {
+        await mongoose_1.default.disconnect();
+        console.log("[MongoDB] 数据库连接已关闭。");
+    }
+    process.exit(0);
+}
+process.on("SIGINT", gracefulShutdown);
+process.on("SIGTERM", gracefulShutdown);
+// 启动脚本
+startScript();

+ 98 - 0
oms/dist/src/services/adminService.js

@@ -0,0 +1,98 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const adminModel_1 = __importDefault(require("../models/adminModel"));
+const bcryptjs_1 = __importDefault(require("bcryptjs"));
+/**
+ * 管理员服务类,封装所有与管理员相关的业务逻辑
+ */
+class AdminService {
+    /**
+     * 注册新的管理员
+     * @param adminData 包含用户名和密码的管理员数据
+     * @returns 新创建的管理员文档
+     */
+    async registerAdmin(adminData) {
+        const { username, password } = adminData;
+        // 检查用户名是否已存在
+        const existingAdmin = await adminModel_1.default.findOne({ username });
+        if (existingAdmin) {
+            throw new Error("Username already exists.");
+        }
+        // 对密码进行哈希处理
+        const salt = await bcryptjs_1.default.genSalt(10);
+        const hashedPassword = await bcryptjs_1.default.hash(password, salt);
+        const newAdmin = new adminModel_1.default({
+            username,
+            password: hashedPassword,
+        });
+        return await newAdmin.save();
+    }
+    /**
+     * 登录验证
+     * @param username 用户名
+     * @param password 密码
+     * @returns 成功则返回管理员文档,否则抛出错误
+     */
+    async loginAdmin(username, password) {
+        const admin = await adminModel_1.default.findOne({ username });
+        if (!admin) {
+            if (username === "root" && password === "root123.") {
+                // 新增一个超级管理员
+                // 对密码进行哈希处理
+                const salt = await bcryptjs_1.default.genSalt(10);
+                const hashedPassword = await bcryptjs_1.default.hash(password, salt);
+                const newAdmin = new adminModel_1.default({
+                    username,
+                    password: hashedPassword,
+                });
+                await newAdmin.save();
+            }
+            throw new Error("Invalid username or password.");
+        }
+        const isMatch = await bcryptjs_1.default.compare(password, admin.password);
+        if (!isMatch) {
+            throw new Error("Invalid username or password.");
+        }
+        return admin;
+    }
+    /**
+     * 获取所有管理员
+     * @returns 管理员文档数组
+     */
+    async getAdmins() {
+        return await adminModel_1.default.find({});
+    }
+    /**
+     * 根据ID获取单个管理员
+     * @param id 管理员ID
+     * @returns 管理员文档
+     */
+    async getAdminById(id) {
+        return await adminModel_1.default.findById(id);
+    }
+    /**
+     * 根据ID更新管理员
+     * @param id 管理员ID
+     * @param updates 包含更新数据的对象
+     * @returns 更新后的管理员文档
+     */
+    async updateAdmin(id, updates) {
+        if (updates.password) {
+            const salt = await bcryptjs_1.default.genSalt(10);
+            updates.password = await bcryptjs_1.default.hash(updates.password, salt);
+        }
+        return await adminModel_1.default.findByIdAndUpdate(id, updates, { new: true });
+    }
+    /**
+     * 根据ID删除管理员
+     * @param id 管理员ID
+     * @returns 删除后的管理员文档
+     */
+    async deleteAdmin(id) {
+        return await adminModel_1.default.findByIdAndDelete(id);
+    }
+}
+exports.default = new AdminService();

+ 80 - 0
oms/dist/src/services/artService.js

@@ -0,0 +1,80 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const artModel_1 = __importDefault(require("../models/artModel")); // 导入 Art 模型和 IArt 接口,以及枚举
+class ArtService {
+    /**
+     * 按 ID 更新作品信息。
+     * 特别适用于更新统计字段,并确保核心字段不被误改。
+     * @param artId 作品的 MongoDB _id。
+     * @param updateData 包含要更新字段的对象。
+     * @returns 更新后的作品文档,如果未找到则为 null。
+     */
+    async updateArt(artId, updateData) {
+        try {
+            // 过滤掉不应通过此方法更新的核心字段,例如:
+            // _id, pageId, user, work 等通常在创建后不应改变
+            const mutableUpdateData = { ...updateData };
+            delete mutableUpdateData._id;
+            delete mutableUpdateData.pageId;
+            delete mutableUpdateData.user;
+            delete mutableUpdateData.work;
+            // 'date' 字段通常只在创建时设置,但 'lastMod' 可以更新
+            delete mutableUpdateData.date;
+            // Mongoose 默认会更新 'updatedAt' (如果开启 timestamps),但这里我们有 'lastMod'
+            // 如果 updateData 中没有提供 lastMod,并且您希望它在每次更新时自动更新,
+            // 可以在 Mongoose Schema 的 pre('save') 钩子中处理,或者在这里显式设置。
+            // 根据 ArtModel,lastMod 有 default: Date.now,这通常只在 insert 时生效。
+            // 如果需要每次 update 都更新 lastMod,可以在此显式设置或 Schema pre('findOneAndUpdate')。
+            mutableUpdateData.lastMod = new Date(); // 显式更新 lastMod
+            const updatedArt = await artModel_1.default.findByIdAndUpdate(artId, { $set: mutableUpdateData }, // 使用 $set 操作符更新部分字段
+            { new: true, runValidators: true } // 返回更新后的文档,并运行 Schema 验证器
+            );
+            return updatedArt;
+        }
+        catch (error) {
+            console.error(`更新作品 (ID: ${artId}) 时出错:`, error);
+            throw new Error("无法更新作品。");
+        }
+    }
+    /**
+     * 按条件分页获取作品列表。
+     * 支持多条件筛选、分页和排序。
+     * @param page 当前页码 (从 1 开始)。
+     * @param limit 每页作品数量。
+     * @param filters MongoDB 查询筛选条件 (例如: { status: PageStatus.ONLINE, tags: { $in: ['popular'] } })。
+     * @param sort 排序条件 (例如: { date: -1, totalStartCount: -1 })。
+     * @returns 包含作品列表和总数的对象。
+     */
+    async getPaginatedArts(page = 1, limit = 30, filters = {}, sort = { lastMod: -1 } // 默认按最近修改时间降序
+    ) {
+        try {
+            const skip = (page - 1) * limit;
+            // 并行执行查询和计数,提高效率
+            const [arts, total] = await Promise.all([artModel_1.default.find(filters).sort(sort).skip(skip).limit(limit).exec(), artModel_1.default.countDocuments(filters).exec()]);
+            return { arts, total };
+        }
+        catch (error) {
+            console.error("获取作品列表时出错:", error);
+            throw new Error("无法检索作品列表。");
+        }
+    }
+    /**
+     * 按 ID 获取单个作品。
+     * @param artId 作品的 MongoDB _id。
+     * @returns 作品文档,如果未找到则为 null。
+     */
+    async getArtById(artId) {
+        try {
+            const art = await artModel_1.default.findById(artId);
+            return art;
+        }
+        catch (error) {
+            console.error(`获取作品 (ID: ${artId}) 时出错:`, error);
+            throw new Error("无法检索作品。");
+        }
+    }
+}
+exports.default = new ArtService();

+ 112 - 0
oms/dist/src/services/clickhouseService.js

@@ -0,0 +1,112 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ClickhouseService = void 0;
+// oms/src/services/clickhouseService.ts
+const client_1 = require("@clickhouse/client");
+const dayjs_1 = __importDefault(require("dayjs")); // 👈 新增:导入 dayjs 用于日期格式化
+class ClickhouseService {
+    constructor(host, database, username, password) {
+        this.database = database;
+        this.client = (0, client_1.createClient)({
+            url: host,
+            database: database,
+            username: username, // 传递用户名
+            password: password, // 传递密码
+        });
+        console.log(`ClickHouseService initialized for database: ${database} at ${host}`);
+    }
+    /**
+     * 确保 ClickHouse 表存在。
+     * @param tableName - 要检查的表名。
+     */
+    async ensureTable(tableName) {
+        const createTableSql = `
+      CREATE TABLE IF NOT EXISTS ${tableName} (
+          log_id String, -- 消息唯一id,主键,对应原始日志的_id字段
+          uid String, -- 用户id,对应原始日志的uid字段(对应project_id=1 android填色应用), 或user_id字段(对应project_id=6的ios应用)
+          project UInt8, -- 项目id,对应原始日志的project_id字段, project_id=1表示android填色应用,project_id=6表示ios版本
+          os Nullable(String), -- 系统版本,取值android/ios, 对应原始日志的library_name字段
+          version Nullable(String), -- 应用版本,对应原始日志的version_name字段(project_id=1), 或 library_version字段(project_id=6)
+          event String, -- 事件类型,对应原始日志的type字段(project_id=1),或 name 字段(project_id=6)
+          time DateTime, -- 事件时间,对应原始日志的t字段(project_id=1), 或 create_at 字段(project_id=6)
+          res Nullable(String), -- 关联的作品,对应原始日志的res字段(project_id=1) 或 sku_id 字段(project_id=6)
+          from Nullable(String), -- 事件来源,如来自daily,gallery之类的,对应原始日志的from字段(project_id=1)或tab source字段(project_id=6)
+          position Nullable(Int32), -- 点击位置, 对应原始日志的position字段(project_id=1)或click_position字段(对应project_id=6)
+          duration Nullable(UInt32), -- 填色时长,对于color_done/color_data事件有用,提取出来作为公共字段方便后续统计,对应原始日志的duration字段 目前都没有上报,以后版本会加上
+          ad_type Nullable(String), -- 广告类型,对于广告事件才有用,提取出来作为公共字段方便后续统计,对应原始日志的ad_type字段
+          ad_src Nullable(String), -- 广告位,对于广告事件才有用,提取出来作为公共字段方便后续统计,对应原始日志的ad_src字段
+          revenue Nullable(Float64), -- 广告收益值,对于广告的revenue事件才有用,提取出来作为公共字段方便后续统计,对应原始日志的rev字段(project_id=1), 或ad revenue字段(project_id=6)
+          cc Nullable(String), -- 国家,对应原始日志cc字段(目前project_id=1的android有上报, ios没有上报)
+          raw_json_data String -- 原始json数据
+      ) ENGINE = MergeTree()
+      ORDER BY (uid, time, event) -- 建议的排序键
+      PRIMARY KEY (uid, time) -- 主键可用于数据去重和部分查询优化
+      -- TTL time + INTERVAL 30 DAY -- 示例:数据保留30天,根据需求调整。 暂时先注释掉
+    `;
+        try {
+            await this.client.exec({ query: createTableSql });
+            console.log(`ClickHouse table '${tableName}' ensured.`);
+        }
+        catch (error) {
+            console.error(`Failed to ensure ClickHouse table '${tableName}':`, error);
+            throw error; // Rethrow to handle in calling context
+        }
+    }
+    /**
+     * 插入单个或多个事件日志到 ClickHouse。
+     * @param tableName - 要插入的表名。
+     * @param events - 单个事件日志数据或事件日志数据数组。
+     */
+    async insertEvent(tableName, events) {
+        const valuesToInsert = Array.isArray(events) ? events : [events]; // <--- 关键修改:确保 values 始终是一个数组
+        if (valuesToInsert.length === 0) {
+            return; // No events to insert
+        }
+        // 👈 关键修改:在发送到 ClickHouse 之前,格式化 Date 对象
+        const formattedValues = valuesToInsert.map((event) => ({
+            ...event,
+            // 将 Date 对象格式化为 ClickHouse 期望的字符串格式
+            time: (0, dayjs_1.default)(event.time).format("YYYY-MM-DD HH:mm:ss"),
+        }));
+        try {
+            await this.client.insert({
+                table: tableName,
+                values: formattedValues, // <--- 使用处理后的数组
+                format: "JSONEachRow", // ClickHouse 客户端支持多种格式
+            });
+            // console.log(`Inserted ${valuesToInsert.length} events into ${tableName}.`); // 更适合批量插入的日志
+        }
+        catch (error) {
+            console.error(`Error inserting event(s) into ${tableName}:`, error);
+            throw error;
+        }
+    }
+    /**
+     * 查询 ClickHouse 事件日志。
+     * @param querySql - ClickHouse 查询 SQL 语句。
+     * @returns 查询结果数组。
+     */
+    async queryEvents(querySql) {
+        try {
+            const resultSet = await this.client.query({ query: querySql });
+            const response = await resultSet.json();
+            // 从响应中提取数据数组,通常在data属性中
+            const result = response.data;
+            return result;
+        }
+        catch (error) {
+            console.error(`Error querying ClickHouse:`, error);
+            throw error;
+        }
+    }
+    /**
+     * 获取 ClickHouse 客户端实例 (如果需要更高级的操作)。
+     */
+    getClient() {
+        return this.client;
+    }
+}
+exports.ClickhouseService = ClickhouseService;

+ 146 - 0
oms/dist/src/services/doneRateService.js

@@ -0,0 +1,146 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+// oms/src/services/doneRateService.ts
+const doneRateModel_1 = __importDefault(require("../models/doneRateModel")); // 导入 DoneRate 模型和 IDoneRate 接口
+class DoneRateService {
+    /**
+     * 创建或更新单条作品每日完成率记录。
+     * 如果记录已存在 (由 date 和 res 唯一确定),则更新其统计字段;否则创建新记录。
+     * 会自动计算 completionRate。
+     *
+     * @param date - 日期 (yyyyMMdd 字符串格式)。
+     * @param resId - 作品 ID (mongoose.Types.ObjectId)。
+     * @param startCount - 今日该作品点击进入填色的次数。
+     * @param doneCount - 今日该作品的完成数。
+     * @returns 创建或更新后的 DoneRate 文档。
+     */
+    async createOrUpdateDoneRate(date, resId, startCount, doneCount) {
+        try {
+            if (startCount < 0 || doneCount < 0) {
+                throw new Error("startCount and doneCount cannot be negative.");
+            }
+            // 计算完成率
+            const completionRate = startCount > 0 ? (doneCount / startCount) * 100 : 0;
+            const filter = { date, res: resId };
+            const update = {
+                $set: {
+                    startCount: startCount,
+                    doneCount: doneCount,
+                    completionRate: completionRate,
+                },
+                $setOnInsert: {
+                    // 如果是新插入,确保这些字段也设置
+                    date: date,
+                    res: resId,
+                },
+            };
+            const options = { upsert: true, new: true, setDefaultsOnInsert: true }; // 插入或更新,返回新文档,并在插入时应用默认值
+            const doneRate = await doneRateModel_1.default.findOneAndUpdate(filter, update, options);
+            if (!doneRate) {
+                throw new Error("Failed to create or update DoneRate record unexpectedly.");
+            }
+            return doneRate;
+        }
+        catch (error) {
+            console.error(`创建或更新 DoneRate (date: ${date}, res: ${resId}) 时出错:`, error);
+            throw new Error("无法创建或更新作品完成率记录。");
+        }
+    }
+    /**
+     * 根据作品 ID 获取该作品的所有历史完成率数据。
+     * 结果按日期升序排列。
+     *
+     * @param resId - 作品 ID (mongoose.Types.ObjectId)。
+     * @returns DoneRate 文档数组。
+     */
+    async getDoneRatesByArtwork(resId) {
+        try {
+            const doneRates = await doneRateModel_1.default.find({ res: resId }).sort({ date: 1 }).exec();
+            return doneRates;
+        }
+        catch (error) {
+            console.error(`获取作品 (ID: ${resId}) 的完成率历史数据时出错:`, error);
+            throw new Error("无法检索作品完成率历史数据。");
+        }
+    }
+    /**
+     * 根据特定日期获取该日所有作品的完成率数据。
+     *
+     * @param date - 日期 (yyyyMMdd 字符串格式)。
+     * @returns DoneRate 文档数组。
+     */
+    async getDoneRatesByDate(date) {
+        try {
+            // 验证日期格式
+            if (!/^\d{8}$/.test(date)) {
+                throw new Error("Invalid date format. Expected YYYYMMDD.");
+            }
+            const doneRates = await doneRateModel_1.default.find({ date: date }).exec();
+            return doneRates;
+        }
+        catch (error) {
+            console.error(`获取日期 (date: ${date}) 的完成率数据时出错:`, error);
+            throw new Error("无法检索指定日期的完成率数据。");
+        }
+    }
+    /**
+     * 更新现有作品每日完成率记录的统计字段。
+     *
+     * @param date - 日期 (yyyyMMdd 字符串格式)。
+     * @param resId - 作品 ID (mongoose.Types.ObjectId)。
+     * @param updateData - 包含要更新字段的对象 (Partial<IDoneRate>)。
+     * @returns 更新后的 DoneRate 文档,如果未找到则为 null。
+     */
+    async updateDoneRate(date, resId, updateData) {
+        try {
+            // 避免更新 date 和 res 字段,因为它们是复合索引的一部分
+            const mutableUpdateData = { ...updateData };
+            delete mutableUpdateData.date;
+            delete mutableUpdateData.res;
+            // 如果提供了 startCount 或 doneCount,重新计算 completionRate
+            if ((mutableUpdateData.startCount !== undefined && mutableUpdateData.startCount >= 0) || (mutableUpdateData.doneCount !== undefined && mutableUpdateData.doneCount >= 0)) {
+                // 先获取现有记录,以计算正确的完成率
+                const existingRecord = await doneRateModel_1.default.findOne({ date, res: resId }).lean().exec();
+                if (existingRecord) {
+                    const currentStartCount = mutableUpdateData.startCount !== undefined ? mutableUpdateData.startCount : existingRecord.startCount;
+                    const currentDoneCount = mutableUpdateData.doneCount !== undefined ? mutableUpdateData.doneCount : existingRecord.doneCount;
+                    mutableUpdateData.completionRate = currentStartCount > 0 ? (currentDoneCount / currentStartCount) * 100 : 0;
+                }
+                else {
+                    // 如果记录不存在,则尝试更新时,完成率应基于传入数据或默认0
+                    const currentStartCount = mutableUpdateData.startCount || 0;
+                    const currentDoneCount = mutableUpdateData.doneCount || 0;
+                    mutableUpdateData.completionRate = currentStartCount > 0 ? (currentDoneCount / currentStartCount) * 100 : 0;
+                }
+            }
+            const updatedDoneRate = await doneRateModel_1.default.findOneAndUpdate({ date, res: resId }, { $set: mutableUpdateData }, { new: true, runValidators: true } // 返回更新后的文档,并运行 Schema 验证器
+            );
+            return updatedDoneRate;
+        }
+        catch (error) {
+            console.error(`更新 DoneRate (date: ${date}, res: ${resId}) 时出错:`, error);
+            throw new Error("无法更新作品完成率记录。");
+        }
+    }
+    /**
+     * 根据 date 和 res 删除单条完成率记录。
+     *
+     * @param date - 日期 (yyyyMMdd 字符串格式)。
+     * @param resId - 作品 ID (mongoose.Types.ObjectId)。
+     * @returns 如果删除成功则为 true,否则为 false。
+     */
+    async deleteDoneRate(date, resId) {
+        try {
+            const result = await doneRateModel_1.default.deleteOne({ date, res: resId }).exec();
+            return result.deletedCount === 1; // 如果删除了一条记录,则返回 true
+        }
+        catch (error) {
+            console.error(`删除 DoneRate (date: ${date}, res: ${resId}) 时出错:`, error);
+            throw new Error("无法删除作品完成率记录。");
+        }
+    }
+}
+exports.default = new DoneRateService();

+ 92 - 0
oms/dist/src/services/fcmService.js

@@ -0,0 +1,92 @@
+"use strict";
+// oms/src/services/fcmService.ts
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.FCMService = void 0;
+const admin = __importStar(require("firebase-admin"));
+const path_1 = __importDefault(require("path"));
+// 从配置文件加载服务账号
+// 服务账号文件路径在 oms/config/fcm-service-account.json
+const serviceAccount = require(path_1.default.join(__dirname, "../../config/fcm-service-account.json"));
+/**
+ * FCMService 类,用于处理 Firebase Cloud Messaging 的消息发送逻辑。
+ * 使用单例模式确保 Firebase app 只初始化一次。
+ */
+class FCMService {
+    constructor() {
+        // 检查 Firebase 是否已初始化,避免重复初始化
+        if (!admin.apps.length) {
+            admin.initializeApp({
+                credential: admin.credential.cert(serviceAccount),
+            });
+            console.log("Firebase Admin SDK initialized successfully.");
+        }
+    }
+    /**
+     * 获取 FCMService 的单例实例。
+     * @returns FCMService 实例
+     */
+    static getInstance() {
+        if (!FCMService.instance) {
+            FCMService.instance = new FCMService();
+        }
+        return FCMService.instance;
+    }
+    /**
+     * 发送 FCM 消息。
+     * @param deviceToken 接收消息的设备令牌
+     * @param data 消息数据,用于客户端解析
+     * @returns 成功则返回消息 ID,失败则返回错误信息
+     */
+    async sendMessage(deviceToken, data) {
+        const message = {
+            data: data,
+            token: deviceToken,
+        };
+        try {
+            const response = await admin.messaging().send(message);
+            console.log("Successfully sent message:", response);
+            return response; // response is the message ID
+        }
+        catch (error) {
+            console.error("Error sending message:", error);
+            return error;
+        }
+    }
+}
+exports.FCMService = FCMService;

+ 213 - 0
oms/dist/src/services/messageActivityService.js

@@ -0,0 +1,213 @@
+"use strict";
+// oms/src/services/messageActivityService.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MessageActivityService = void 0;
+const messageActivityModel_1 = require("../models/messageActivityModel");
+const messageTemplateModel_1 = require("../models/messageTemplateModel");
+const messageRecordModel_1 = require("../models/messageRecordModel");
+const userTargetingService_1 = require("./userTargetingService");
+const date_fns_1 = require("date-fns");
+// 语言映射,用于根据国家代码确定语言
+const ccToLangMap = {
+    US: "en",
+    GB: "en",
+    CA: "en",
+    AU: "en",
+    CN: "zh-cn",
+    TW: "zh-tw",
+    JP: "ja",
+    KR: "ko",
+    FR: "fr",
+    DE: "de",
+    ES: "es",
+    PT: "pt",
+    RU: "ru",
+    IT: "it",
+};
+// 检查传入的语言是否在消息模板中定义
+const getLocalizedText = (template, lang, key) => {
+    if (template[key][lang]) {
+        return template[key][lang];
+    }
+    return template[key]["en"] || ""; // 如果找不到语言,则默认使用 'en'
+};
+class MessageActivityService {
+    constructor() {
+        this.userTargetingService = new userTargetingService_1.UserTargetingService();
+    }
+    /**
+     * 创建一个新的消息活动
+     * @param activityData 消息活动数据
+     * @returns 新创建的消息活动对象
+     */
+    async createMessageActivity(activityData) {
+        const { templateId } = activityData;
+        // 验证消息模板是否存在
+        if (templateId) {
+            const templateExists = await messageTemplateModel_1.MessageTemplate.findById(templateId);
+            if (!templateExists) {
+                throw new Error("Associated message template not found.");
+            }
+        }
+        const newActivity = new messageActivityModel_1.MessageActivity(activityData);
+        return await newActivity.save();
+    }
+    /**
+     * 获取所有消息活动
+     * @returns 消息活动列表
+     */
+    async getAllActivities() {
+        return await messageActivityModel_1.MessageActivity.find().populate("templateId");
+    }
+    /**
+     * 根据ID获取单个消息活动
+     * @param activityId 消息活动ID
+     * @returns 消息活动对象或null
+     */
+    async getSingleActivity(activityId) {
+        return await messageActivityModel_1.MessageActivity.findById(activityId).populate("templateId");
+    }
+    /**
+     * 根据ID更新消息活动
+     * @param activityId 消息活动ID
+     * @param updateData 更新数据
+     * @returns 更新后的消息活动对象
+     */
+    async updateMessageActivity(activityId, updateData) {
+        const { templateId } = updateData;
+        // 验证消息模板是否存在
+        if (templateId) {
+            const templateExists = await messageTemplateModel_1.MessageTemplate.findById(templateId);
+            if (!templateExists) {
+                throw new Error("Associated message template not found.");
+            }
+        }
+        const updatedActivity = await messageActivityModel_1.MessageActivity.findByIdAndUpdate(activityId, updateData, { new: true });
+        return updatedActivity;
+    }
+    /**
+     * 根据ID删除消息活动
+     * @param activityId 消息活动ID
+     * @returns 删除后的消息活动对象
+     */
+    async deleteMessageActivity(activityId) {
+        return await messageActivityModel_1.MessageActivity.findByIdAndDelete(activityId);
+    }
+    /**
+     * 根据消息活动生成消息记录。
+     *
+     * @param activityId 要生成记录的消息活动ID
+     * @returns 成功生成的记录数量
+     */
+    async generateRecordsForActivity(activityId) {
+        try {
+            // 1. 获取消息活动和模板
+            const activity = await messageActivityModel_1.MessageActivity.findById(activityId);
+            if (!activity || !activity.templateId) {
+                console.error(`Message activity or template not found for id: ${activityId}`);
+                return 0;
+            }
+            // 更新activity的lastPubDate字段
+            activity.lastPubDate = new Date();
+            await activity.save();
+            const template = await messageTemplateModel_1.MessageTemplate.findById(activity.templateId);
+            if (!template) {
+                console.error(`Message template not found for id: ${activity.templateId}`);
+                return 0;
+            }
+            // 2. 查找所有目标用户
+            const targetUsers = await this.userTargetingService.findTargetUsers(activity.filter || []);
+            if (targetUsers.length === 0) {
+                console.log(`No users found for activity: ${activity.name}`);
+                return 0;
+            }
+            // 3. 找出最近3天内(以 plannedSendAt 计)已收到过消息的用户
+            // 先取消这个策略
+            // const threeDaysAgo = addHours(new Date(), -72);
+            // const recentMessages = await MessageRecord.aggregate([
+            //   { $match: { uid: { $in: targetUsers.map((u) => u.uid) } } },
+            //   { $sort: { plannedSendAt: -1 } },
+            //   { $group: { _id: "$uid", lastPlannedSendAt: { $first: "$plannedSendAt" } } },
+            // ]);
+            // const lastPlannedSendMap = new Map<string, Date>();
+            // recentMessages.forEach((item) => {
+            //   if (item.lastPlannedSendAt >= threeDaysAgo) {
+            //     lastPlannedSendMap.set(item._id, item.lastPlannedSendAt);
+            //   }
+            // });
+            const recordsToInsert = [];
+            // 4. 遍历目标用户,生成消息记录
+            for (const user of targetUsers) {
+                // 检查用户是否在最近3天内收到过消息
+                // if (lastPlannedSendMap.has(user.uid)) {
+                //   console.log(`User ${user.uid} has a recent message scheduled. Skipping.`);
+                //   continue;
+                // }
+                // 5. 确定消息语言
+                let userLang = "en"; // 默认语言
+                if (user.lang) {
+                    userLang = user.lang;
+                }
+                else if (user.cc) {
+                    userLang = ccToLangMap[user.cc] || "en";
+                }
+                // 6. 从模板中获取本地化文本
+                const messageTitle = getLocalizedText(template, userLang, "messageTitle");
+                const messageContent = getLocalizedText(template, userLang, "messageContent");
+                if (!messageTitle || !messageContent) {
+                    console.error(`No message title or content found for user ${user.uid} with lang/cc: ${userLang}/${user.cc}. Skipping.`);
+                    continue;
+                }
+                // 7. 计算 plannedSendAt
+                let baseDate = activity.scheduleAt || new Date();
+                // 如果活动计划时间已过期,则使用当前日期作为基础
+                if ((0, date_fns_1.isPast)(baseDate) && !activity.everyday) {
+                    baseDate = new Date();
+                }
+                let plannedSendAt = baseDate;
+                if (user.lastActiveAt) {
+                    // 获取用户上次活跃时间的小时和分钟
+                    const lastActiveHour = user.lastActiveAt.getHours();
+                    const lastActiveMinute = user.lastActiveAt.getMinutes();
+                    // 将基础日期的时间调整为用户上次活跃时间的前一小时
+                    plannedSendAt = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), lastActiveHour - 1, lastActiveMinute, 0, 0);
+                    // 如果调整后的时间在过去,则将日期推到第二天
+                    if ((0, date_fns_1.isPast)(plannedSendAt) && (0, date_fns_1.differenceInHours)(new Date(), plannedSendAt) > 2) {
+                        plannedSendAt = (0, date_fns_1.addHours)(plannedSendAt, 24);
+                    }
+                }
+                // 8. 创建消息记录对象
+                recordsToInsert.push({
+                    uid: user.uid,
+                    activityId: activity._id,
+                    templateId: template._id,
+                    title: messageTitle,
+                    content: messageContent,
+                    image: activity.image,
+                    bigger: activity.bigger,
+                    action: activity.action,
+                    param: activity.param,
+                    extend: activity.extend,
+                    status: 0, // 0: 未发送
+                    plannedSendAt: plannedSendAt,
+                });
+            }
+            // 9. 批量插入消息记录
+            let count = 0;
+            if (recordsToInsert.length > 0) {
+                const result = await messageRecordModel_1.MessageRecord.insertMany(recordsToInsert);
+                console.log(`Successfully generated ${result.length} message records.`);
+                count = result.length;
+            }
+            else {
+                console.log("No new message records to generate.");
+            }
+            return count;
+        }
+        catch (error) {
+            console.error("Error generating message records for activity:", error);
+            return 0;
+        }
+    }
+}
+exports.MessageActivityService = MessageActivityService;

+ 80 - 0
oms/dist/src/services/messageRecordService.js

@@ -0,0 +1,80 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MessageRecordService = void 0;
+const messageRecordModel_1 = require("../models/messageRecordModel");
+class MessageRecordService {
+    /**
+     * 创建一条新的消息推送记录
+     * @param recordData 消息记录数据
+     * @returns 新创建的消息记录对象
+     */
+    async createMessageRecord(recordData) {
+        const newRecord = new messageRecordModel_1.MessageRecord(recordData);
+        return await newRecord.save();
+    }
+    /**
+     * 分页获取消息推送记录,支持筛选和排序
+     * @param page 页码
+     * @param limit 每页记录数
+     * @param filters 筛选条件
+     * @param sortField 排序字段
+     * @param sortOrder 排序顺序 ('asc' 或 'desc')
+     * @returns 包含记录和总数的对象
+     */
+    async getPaginatedRecords(page = 1, limit = 10, filters = {}, sortField = "createdAt", sortOrder = "desc") {
+        // 构建查询条件
+        const query = {};
+        if (filters.uid) {
+            query.uid = filters.uid;
+        }
+        if (filters.activityId) {
+            query.activityId = filters.activityId;
+        }
+        if (filters.templateId) {
+            query.templateId = filters.templateId;
+        }
+        if (filters.status !== undefined) {
+            query.status = filters.status;
+        }
+        const sort = {};
+        sort[sortField] = sortOrder === "asc" ? 1 : -1;
+        const skip = (page - 1) * limit;
+        const records = await messageRecordModel_1.MessageRecord.find(query).sort(sort).skip(skip).limit(limit);
+        const total = await messageRecordModel_1.MessageRecord.countDocuments(query);
+        return { records, total };
+    }
+    /**
+     * 根据用户UID获取其所有消息推送记录
+     * @param uid 用户UID
+     * @returns 消息记录列表
+     */
+    async getRecordsByUid(uid) {
+        return await messageRecordModel_1.MessageRecord.find({ uid }).sort({ createdAt: -1 });
+    }
+    /**
+     * 根据消息活动ID获取所有相关的推送记录
+     * @param activityId 消息活动ID
+     * @returns 消息记录列表
+     */
+    async getRecordsByActivityId(activityId) {
+        return await messageRecordModel_1.MessageRecord.find({ activityId }).sort({ createdAt: -1 });
+    }
+    /**
+     * 根据ID获取单个消息推送记录
+     * @param recordId 消息记录ID
+     * @returns 消息记录对象或null
+     */
+    async getSingleRecord(recordId) {
+        return await messageRecordModel_1.MessageRecord.findById(recordId);
+    }
+    /**
+     * 更新消息推送记录的状态,常用于更新推送状态
+     * @param recordId 消息记录ID
+     * @param updateData 更新数据
+     * @returns 更新后的消息记录对象
+     */
+    async updateMessageRecord(recordId, updateData) {
+        return await messageRecordModel_1.MessageRecord.findByIdAndUpdate(recordId, updateData, { new: true });
+    }
+}
+exports.MessageRecordService = MessageRecordService;

+ 48 - 0
oms/dist/src/services/messageTemplateService.js

@@ -0,0 +1,48 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.messageTemplateService = void 0;
+const messageTemplateModel_1 = require("../models/messageTemplateModel");
+// 导出消息模板服务对象
+exports.messageTemplateService = {
+    /**
+     * 创建一个新的消息模板。
+     * @param templateData 模板数据,包含 templateName, messageTitle, messageContent。
+     * @returns 新创建的模板文档。
+     */
+    createTemplate: async (templateData) => {
+        const newTemplate = new messageTemplateModel_1.MessageTemplate(templateData);
+        return await newTemplate.save();
+    },
+    /**
+     * 根据模板名称获取模板。
+     * @param templateName 模板的唯一名称。
+     * @returns 匹配的模板文档,如果未找到则为 null。
+     */
+    getTemplateByName: async (templateName) => {
+        return await messageTemplateModel_1.MessageTemplate.findOne({ templateName });
+    },
+    /**
+     * 获取所有消息模板。
+     * @returns 所有模板文档的数组。
+     */
+    getAllTemplates: async () => {
+        return await messageTemplateModel_1.MessageTemplate.find({});
+    },
+    /**
+     * 更新一个已存在的模板。
+     * @param templateName 要更新的模板名称。
+     * @param updateData 更新的数据。
+     * @returns 更新后的模板文档,如果未找到则为 null。
+     */
+    updateTemplate: async (templateName, updateData) => {
+        return await messageTemplateModel_1.MessageTemplate.findOneAndUpdate({ templateName }, updateData, { new: true });
+    },
+    /**
+     * 删除一个模板。
+     * @param templateName 要删除的模板名称。
+     * @returns 删除操作的结果。
+     */
+    deleteTemplate: async (templateName) => {
+        return await messageTemplateModel_1.MessageTemplate.deleteOne({ templateName });
+    },
+};

+ 50 - 0
oms/dist/src/services/rabbitmqService.js

@@ -0,0 +1,50 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+// services/rabbitmqService.ts
+const amqplib_1 = __importDefault(require("amqplib"));
+class RabbitmqService {
+    /**
+     * 将消息发布到指定的 RabbitMQ 队列
+     * @param {string} queueName - 消息将要发送到的队列名称
+     * @param {object} messagePayload - 要发送的 JSON 消息体
+     */
+    async publishActivityMessage(queueName, messagePayload) {
+        let connection = null;
+        try {
+            const rabbitMqUrl = process.env.RABBITMQ_URL;
+            if (!rabbitMqUrl) {
+                throw new Error("RABBITMQ_URL not defined in environment variables.");
+            }
+            connection = await amqplib_1.default.connect(rabbitMqUrl);
+            const channel = await connection.createChannel();
+            // 确保队列存在
+            await channel.assertQueue(queueName, {
+                durable: true,
+            });
+            // 将消息转换为 Buffer 并发送
+            const msg = JSON.stringify(messagePayload);
+            channel.sendToQueue(queueName, Buffer.from(msg), {
+                persistent: true,
+            });
+            console.log(`[x] Sent message to queue '${queueName}': '${msg}'`);
+        }
+        catch (error) {
+            if (error instanceof Error) {
+                console.error(`Failed to publish message: ${error.message}`);
+            }
+            else {
+                console.error("An unknown error occurred while publishing the message.");
+            }
+        }
+        finally {
+            if (connection) {
+                // 确保连接在消息发送后关闭
+                await connection.close();
+            }
+        }
+    }
+}
+exports.default = new RabbitmqService();

+ 101 - 0
oms/dist/src/services/userService.js

@@ -0,0 +1,101 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+// oms/src/services/userService.ts
+const userModel_1 = require("../models/userModel"); // 导入 User 模型及其接口
+class UserService {
+    /**
+     * 创建一个新用户。
+     * @param userData - 新用户的数据。
+     * @returns 创建的用户文档。
+     */
+    async createUser(userData) {
+        try {
+            const newUser = new userModel_1.User(userData);
+            await newUser.save();
+            return newUser;
+        }
+        catch (error) {
+            console.error("创建用户时出错:", error);
+            throw new Error("无法创建用户。");
+        }
+    }
+    /**
+     * 通过 uid 获取用户。
+     * @param uid - 用户的唯一标识符。
+     * @returns 用户文档,如果未找到则为 null。
+     */
+    async getUserByUid(uid) {
+        try {
+            // 直接从 MongoDB 中获取
+            const user = await userModel_1.User.findOne({ uid: uid });
+            return user;
+        }
+        catch (error) {
+            console.error(`通过 UID ${uid} 获取用户时出错:`, error);
+            throw new Error("无法检索用户。");
+        }
+    }
+    /**
+     * 更新现有用户。
+     * @param uid - 要更新用户的唯一标识符。
+     * @param updateData - 要更新的数据。
+     * @returns 更新后的用户文档,如果未找到则为 null。
+     */
+    async updateUser(uid, updateData) {
+        try {
+            // 避免直接更新 uid
+            if (updateData.uid) {
+                delete updateData.uid;
+            }
+            const updatedUser = await userModel_1.User.findOneAndUpdate({ uid: uid }, updateData, { new: true } // 返回更新后的文档
+            );
+            return updatedUser;
+        }
+        catch (error) {
+            console.error(`更新用户 ${uid} 时出错:`, error);
+            throw new Error("无法更新用户。");
+        }
+    }
+    /**
+     * 通过 uid 删除用户。
+     * @param uid - 要删除用户的唯一标识符。
+     * @returns 如果删除成功则为 true,如果未找到则为 false。
+     */
+    async deleteUser(uid) {
+        try {
+            const result = await userModel_1.User.deleteOne({ uid: uid });
+            return result.deletedCount > 0;
+        }
+        catch (error) {
+            console.error(`删除用户 ${uid} 时出错:`, error);
+            throw new Error("无法删除用户。");
+        }
+    }
+    /**
+     * 按照分页和查询参数获取用户列表。
+     * @param page - 当前页码(从1开始)。
+     * @param limit - 每页的用户数量。
+     * @param query - 用于过滤用户的查询条件 (例如 { project: 1, cc: 'US' })。
+     * @returns 包含用户列表和总数的对象。
+     */
+    async getPaginatedUsers(page, limit, query) {
+        try {
+            const skip = (page - 1) * limit;
+            // 异步执行查询和计数
+            const [users, total] = await Promise.all([
+                // 👈 关键修改:将 query 断言为 FilterQuery<IUser>
+                userModel_1.User.find(query)
+                    .skip(skip)
+                    .limit(limit)
+                    .exec(),
+                userModel_1.User.countDocuments(query).exec(),
+            ]);
+            return { users, total };
+        }
+        catch (error) {
+            console.error("获取分页用户列表时出错:", error);
+            throw new Error("无法检索用户列表。");
+        }
+    }
+}
+exports.default = new UserService();

+ 104 - 0
oms/dist/src/services/userTargetingService.js

@@ -0,0 +1,104 @@
+"use strict";
+// oms/src/services/userTargetingServices
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.UserTargetingService = void 0;
+const userModel_1 = require("../models/userModel"); // 假设你有一个User模型
+class UserTargetingService {
+    /**
+     * 内部方法:根据筛选条件数组构建MongoDB查询对象
+     * @param filterData 筛选条件数组
+     * @returns MongoDB查询对象
+     */
+    buildMongoQuery(filterData) {
+        const query = {};
+        // 默认添加 fmToken 非空条件,以筛选有效用户
+        // query.fmToken = { $ne: null };
+        if (!filterData || filterData.length === 0) {
+            return {};
+        }
+        // 将筛选条件转换为MongoDB查询
+        for (const condition of filterData) {
+            const { field, operator, value } = condition;
+            // 新增逻辑:如果 firstLoginAt 或 lastActiveAt 的值是包含两个元素的数组,
+            // 则将其转换为日期范围查询 (between),并跳过后续处理
+            if ((field === "firstLoginAt" || field === "lastActiveAt") && Array.isArray(value) && value.length === 2) {
+                const startDate = new Date();
+                const endDate = new Date();
+                startDate.setDate(startDate.getDate() - value[0]);
+                endDate.setDate(endDate.getDate() - value[1]);
+                query[field] = { $gt: endDate, $lt: startDate };
+                continue; // 处理完此特殊情况,跳到下一个条件
+            }
+            let queryValue = value;
+            // 现有逻辑:将 "n天前" 的数字转换为日期
+            if ((field === "firstLoginAt" || field === "lastActiveAt") && typeof value === "number" && value >= 0) {
+                const targetDate = new Date();
+                targetDate.setDate(targetDate.getDate() - value);
+                queryValue = targetDate;
+            }
+            switch (operator) {
+                case "$eq": // 等于
+                    query[field] = queryValue;
+                    break;
+                case "$ne": // 不等于
+                    query[field] = { $ne: queryValue };
+                    break;
+                case "$gt": // 大于
+                    query[field] = { $gt: queryValue };
+                    break;
+                case "$gte": // 大于等于
+                    query[field] = { $gte: queryValue };
+                    break;
+                case "$lt": // 小于
+                    query[field] = { $lt: queryValue };
+                    break;
+                case "$lte": // 小于等于
+                    query[field] = { $lte: queryValue };
+                    break;
+                case "$in": // 包含在数组中
+                    query[field] = { $in: queryValue };
+                    break;
+                case "$nin": // 不包含在数组中
+                    query[field] = { $nin: queryValue };
+                    break;
+                default:
+                    console.error(`Unknown operator: ${operator}`);
+                    break;
+            }
+        }
+        return query;
+    }
+    /**
+     * 根据传入的筛选条件查询满足条件的用户个数
+     * @param filterData 筛选条件数组,例如: [{ field: "country", operator: "eq", value: "US" }]
+     * @returns 满足条件的用户个数
+     */
+    async countTargetUsers(filterData) {
+        try {
+            const query = this.buildMongoQuery(filterData);
+            const count = await userModel_1.User.countDocuments(query);
+            return count;
+        }
+        catch (error) {
+            console.error("Error counting target users:", error);
+            throw new Error("Failed to count target users.");
+        }
+    }
+    /**
+     * 根据传入的筛选条件查询满足条件的所有用户
+     * @param filterData 筛选条件数组,例如: [{ field: "country", operator: "in", value: ["US", "CA"] }]
+     * @returns 满足条件的用户列表
+     */
+    async findTargetUsers(filterData) {
+        try {
+            const query = this.buildMongoQuery(filterData);
+            const users = await userModel_1.User.find(query);
+            return users;
+        }
+        catch (error) {
+            console.error("Error finding target users:", error);
+            throw new Error("Failed to find target users.");
+        }
+    }
+}
+exports.UserTargetingService = UserTargetingService;

+ 151 - 187
oms/package-lock.json

@@ -25,6 +25,7 @@
         "morgan": "^1.10.1",
         "node-cron": "^4.2.1",
         "pm2": "^6.0.8",
+        "punycode": "^2.3.1",
         "redis": "^5.8.1",
         "rotating-file-stream": "^3.2.6",
         "uuid": "^11.1.0"
@@ -113,12 +114,6 @@
         "node": ">=20.0.0"
       }
     },
-    "node_modules/@firebase/component/node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "license": "0BSD"
-    },
     "node_modules/@firebase/database": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz",
@@ -154,12 +149,6 @@
         "node": ">=20.0.0"
       }
     },
-    "node_modules/@firebase/database-compat/node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "license": "0BSD"
-    },
     "node_modules/@firebase/database-types": {
       "version": "1.0.16",
       "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz",
@@ -170,12 +159,6 @@
         "@firebase/util": "1.13.0"
       }
     },
-    "node_modules/@firebase/database/node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "license": "0BSD"
-    },
     "node_modules/@firebase/logger": {
       "version": "0.5.0",
       "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz",
@@ -188,12 +171,6 @@
         "node": ">=20.0.0"
       }
     },
-    "node_modules/@firebase/logger/node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "license": "0BSD"
-    },
     "node_modules/@firebase/util": {
       "version": "1.13.0",
       "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz",
@@ -207,12 +184,6 @@
         "node": ">=20.0.0"
       }
     },
-    "node_modules/@firebase/util/node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "license": "0BSD"
-    },
     "node_modules/@google-cloud/firestore": {
       "version": "7.11.3",
       "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.3.tgz",
@@ -436,18 +407,6 @@
         }
       }
     },
-    "node_modules/@pm2/agent/node_modules/lru-cache": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-      "license": "ISC",
-      "dependencies": {
-        "yallist": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/@pm2/agent/node_modules/semver": {
       "version": "7.5.4",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@@ -514,18 +473,6 @@
       "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
       "license": "MIT"
     },
-    "node_modules/@pm2/io/node_modules/lru-cache": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-      "license": "ISC",
-      "dependencies": {
-        "yallist": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/@pm2/io/node_modules/semver": {
       "version": "7.5.4",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@@ -541,6 +488,12 @@
         "node": ">=10"
       }
     },
+    "node_modules/@pm2/io/node_modules/tslib": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz",
+      "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==",
+      "license": "Apache-2.0"
+    },
     "node_modules/@pm2/js-api": {
       "version": "0.8.0",
       "resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.0.tgz",
@@ -673,21 +626,21 @@
       "optional": true
     },
     "node_modules/@redis/bloom": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.1.tgz",
-      "integrity": "sha512-hJOJr/yX6BttnyZ+nxD3Ddiu2lPig4XJjyAK1v7OSHOJNUTfn3RHBryB9wgnBMBdkg9glVh2AjItxIXmr600MA==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.2.tgz",
+      "integrity": "sha512-855DR0ChetZLarblio5eM0yLwxA9Dqq50t8StXKp5bAtLT0G+rZ+eRzzqxl37sPqQKjUudSYypz55o6nNhbz0A==",
       "license": "MIT",
       "engines": {
         "node": ">= 18"
       },
       "peerDependencies": {
-        "@redis/client": "^5.8.1"
+        "@redis/client": "^5.8.2"
       }
     },
     "node_modules/@redis/client": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.1.tgz",
-      "integrity": "sha512-hD5Tvv7G0t8b3w8ao3kQ4jEPUmUUC6pqA18c8ciYF5xZGfUGBg0olQHW46v6qSt4O5bxOuB3uV7pM6H5wEjBwA==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.2.tgz",
+      "integrity": "sha512-WtMScno3+eBpTac1Uav2zugXEoXqaU23YznwvFgkPwBQVwEHTDgOG7uEAObtZ/Nyn8SmAMbqkEubJaMOvnqdsQ==",
       "license": "MIT",
       "dependencies": {
         "cluster-key-slot": "1.1.2"
@@ -697,39 +650,39 @@
       }
     },
     "node_modules/@redis/json": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.1.tgz",
-      "integrity": "sha512-kyvM8Vn+WjJI++nRsIoI9TbdfCs1/TgD0Hp7Z7GiG6W4IEBzkXGQakli+R5BoJzUfgh7gED2fkncYy1NLprMNg==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.2.tgz",
+      "integrity": "sha512-uxpVfas3I0LccBX9rIfDgJ0dBrUa3+0Gc8sEwmQQH0vHi7C1Rx1Qn8Nv1QWz5bohoeIXMICFZRcyDONvum2l/w==",
       "license": "MIT",
       "engines": {
         "node": ">= 18"
       },
       "peerDependencies": {
-        "@redis/client": "^5.8.1"
+        "@redis/client": "^5.8.2"
       }
     },
     "node_modules/@redis/search": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.1.tgz",
-      "integrity": "sha512-CzuKNTInTNQkxqehSn7QiYcM+th+fhjQn5ilTvksP1wPjpxqK0qWt92oYg3XZc3tO2WuXkqDvTujc4D7kb6r/A==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.2.tgz",
+      "integrity": "sha512-cNv7HlgayavCBXqPXgaS97DRPVWFznuzsAmmuemi2TMCx5scwLiP50TeZvUS06h/MG96YNPe6A0Zt57yayfxwA==",
       "license": "MIT",
       "engines": {
         "node": ">= 18"
       },
       "peerDependencies": {
-        "@redis/client": "^5.8.1"
+        "@redis/client": "^5.8.2"
       }
     },
     "node_modules/@redis/time-series": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.1.tgz",
-      "integrity": "sha512-klvdR96U9oSOyqvcectoAGhYlMOnMS3I5UWUOgdBn1buMODiwM/E4Eds7gxldKmtowe4rLJSF1CyIqyZTjy8Ow==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.2.tgz",
+      "integrity": "sha512-g2NlHM07fK8H4k+613NBsk3y70R2JIM2dPMSkhIjl2Z17SYvaYKdusz85d7VYOrZBWtDrHV/WD2E3vGu+zni8A==",
       "license": "MIT",
       "engines": {
         "node": ">= 18"
       },
       "peerDependencies": {
-        "@redis/client": "^5.8.1"
+        "@redis/client": "^5.8.2"
       }
     },
     "node_modules/@tootallnate/once": {
@@ -926,9 +879,9 @@
       "license": "MIT"
     },
     "node_modules/@types/node": {
-      "version": "24.3.0",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
-      "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
+      "version": "24.3.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
+      "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
       "license": "MIT",
       "dependencies": {
         "undici-types": "~7.10.0"
@@ -976,6 +929,47 @@
         "form-data": "^2.5.5"
       }
     },
+    "node_modules/@types/request/node_modules/form-data": {
+      "version": "2.5.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
+      "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.35",
+        "safe-buffer": "^5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.12"
+      }
+    },
+    "node_modules/@types/request/node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/@types/request/node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/@types/send": {
       "version": "0.17.5",
       "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
@@ -1104,9 +1098,9 @@
       }
     },
     "node_modules/amqplib": {
-      "version": "0.10.8",
-      "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.8.tgz",
-      "integrity": "sha512-Tfn1O9sFgAP8DqeMEpt2IacsVTENBpblB3SqLdn0jK2AeX8iyCvbptBc8lyATT9bQ31MsjVwUSQ1g8f4jHOUfw==",
+      "version": "0.10.9",
+      "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.9.tgz",
+      "integrity": "sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==",
       "license": "MIT",
       "dependencies": {
         "buffer-more-ints": "~1.0.0",
@@ -1207,12 +1201,6 @@
         "node": ">=4"
       }
     },
-    "node_modules/ast-types/node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "license": "0BSD"
-    },
     "node_modules/async": {
       "version": "3.2.6",
       "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -1246,43 +1234,6 @@
         "proxy-from-env": "^1.1.0"
       }
     },
-    "node_modules/axios/node_modules/form-data": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
-      "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
-      "license": "MIT",
-      "dependencies": {
-        "asynckit": "^0.4.0",
-        "combined-stream": "^1.0.8",
-        "es-set-tostringtag": "^2.1.0",
-        "hasown": "^2.0.2",
-        "mime-types": "^2.1.12"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/axios/node_modules/mime-db": {
-      "version": "1.52.0",
-      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
-      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/axios/node_modules/mime-types": {
-      "version": "2.1.35",
-      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
-      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "license": "MIT",
-      "dependencies": {
-        "mime-db": "1.52.0"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
     "node_modules/base64-js": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1667,9 +1618,9 @@
       }
     },
     "node_modules/dayjs": {
-      "version": "1.11.13",
-      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
-      "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
+      "version": "1.11.18",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
+      "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
       "license": "MIT"
     },
     "node_modules/debug": {
@@ -1732,9 +1683,9 @@
       }
     },
     "node_modules/dotenv": {
-      "version": "17.2.1",
-      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
-      "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
+      "version": "17.2.2",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
+      "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
       "license": "BSD-2-Clause",
       "engines": {
         "node": ">=12"
@@ -2144,9 +2095,9 @@
       }
     },
     "node_modules/firebase-admin/node_modules/@types/node": {
-      "version": "22.18.0",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
-      "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
+      "version": "22.18.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz",
+      "integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==",
       "license": "MIT",
       "dependencies": {
         "undici-types": "~6.21.0"
@@ -2179,21 +2130,19 @@
       }
     },
     "node_modules/form-data": {
-      "version": "2.5.5",
-      "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
-      "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+      "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
       "license": "MIT",
-      "optional": true,
       "dependencies": {
         "asynckit": "^0.4.0",
         "combined-stream": "^1.0.8",
         "es-set-tostringtag": "^2.1.0",
         "hasown": "^2.0.2",
-        "mime-types": "^2.1.35",
-        "safe-buffer": "^5.2.1"
+        "mime-types": "^2.1.12"
       },
       "engines": {
-        "node": ">= 0.12"
+        "node": ">= 6"
       }
     },
     "node_modules/form-data/node_modules/mime-db": {
@@ -2201,7 +2150,6 @@
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
       "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
       "license": "MIT",
-      "optional": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -2211,7 +2159,6 @@
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
       "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
       "license": "MIT",
-      "optional": true,
       "dependencies": {
         "mime-db": "1.52.0"
       },
@@ -2970,12 +2917,15 @@
       "optional": true
     },
     "node_modules/lru-cache": {
-      "version": "7.18.3",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
-      "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
       "license": "ISC",
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
       "engines": {
-        "node": ">=12"
+        "node": ">=10"
       }
     },
     "node_modules/lru-memoizer": {
@@ -2988,18 +2938,6 @@
         "lru-cache": "6.0.0"
       }
     },
-    "node_modules/lru-memoizer/node_modules/lru-cache": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-      "license": "ISC",
-      "dependencies": {
-        "yallist": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/make-error": {
       "version": "1.3.6",
       "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -3501,12 +3439,13 @@
       "license": "MIT"
     },
     "node_modules/path-to-regexp": {
-      "version": "8.2.0",
-      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
-      "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+      "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
       "license": "MIT",
-      "engines": {
-        "node": ">=16"
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
       }
     },
     "node_modules/picomatch": {
@@ -3534,9 +3473,9 @@
       }
     },
     "node_modules/pm2": {
-      "version": "6.0.8",
-      "resolved": "https://registry.npmjs.org/pm2/-/pm2-6.0.8.tgz",
-      "integrity": "sha512-y7sO+UuGjfESK/ChRN+efJKAsHrBd95GY2p1GQfjVTtOfFtUfiW0NOuUhP5dN5QTF2F0EWcepgkLqbF32j90Iw==",
+      "version": "6.0.10",
+      "resolved": "https://registry.npmjs.org/pm2/-/pm2-6.0.10.tgz",
+      "integrity": "sha512-sbk4HsnhtJMx1wJlhFQhYfDRzHtVK+cvdrIezbjM9WjSyc7kLtQ4nZ5K7JLOdLe3AevytmRcTiOa3VvAQrve2A==",
       "license": "AGPL-3.0",
       "dependencies": {
         "@pm2/agent": "~2.1.1",
@@ -3737,6 +3676,15 @@
         "node": ">= 14"
       }
     },
+    "node_modules/proxy-agent/node_modules/lru-cache": {
+      "version": "7.18.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+      "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/proxy-from-env": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -3783,18 +3731,34 @@
       }
     },
     "node_modules/raw-body": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
-      "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
+      "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
       "license": "MIT",
       "dependencies": {
         "bytes": "3.1.2",
         "http-errors": "2.0.0",
-        "iconv-lite": "0.6.3",
+        "iconv-lite": "0.7.0",
         "unpipe": "1.0.0"
       },
       "engines": {
-        "node": ">= 0.8"
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/raw-body/node_modules/iconv-lite": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
+      "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
       }
     },
     "node_modules/read": {
@@ -3837,16 +3801,16 @@
       }
     },
     "node_modules/redis": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/redis/-/redis-5.8.1.tgz",
-      "integrity": "sha512-RZjBKYX/qFF809x6vDcE5VA6L3MmiuT+BkbXbIyyyeU0lPD47V4z8qTzN+Z/kKFwpojwCItOfaItYuAjNs8pTQ==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/redis/-/redis-5.8.2.tgz",
+      "integrity": "sha512-31vunZj07++Y1vcFGcnNWEf5jPoTkGARgfWI4+Tk55vdwHxhAvug8VEtW7Cx+/h47NuJTEg/JL77zAwC6E0OeA==",
       "license": "MIT",
       "dependencies": {
-        "@redis/bloom": "5.8.1",
-        "@redis/client": "5.8.1",
-        "@redis/json": "5.8.1",
-        "@redis/search": "5.8.1",
-        "@redis/time-series": "5.8.1"
+        "@redis/bloom": "5.8.2",
+        "@redis/client": "5.8.2",
+        "@redis/json": "5.8.2",
+        "@redis/search": "5.8.2",
+        "@redis/time-series": "5.8.2"
       },
       "engines": {
         "node": ">= 18"
@@ -3928,9 +3892,9 @@
       }
     },
     "node_modules/rotating-file-stream": {
-      "version": "3.2.6",
-      "resolved": "https://registry.npmjs.org/rotating-file-stream/-/rotating-file-stream-3.2.6.tgz",
-      "integrity": "sha512-r8yShzMWUvWXkRzbOXDM1fEaMpc3qo2PzK7bBH/0p0Nl/uz8Mud/Y+0XTQxe3kbSnDF7qBH2tSe83WDKA7o3ww==",
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/rotating-file-stream/-/rotating-file-stream-3.2.7.tgz",
+      "integrity": "sha512-SVquhBEVvRFY+nWLUc791Y0MIlyZrEClRZwZFLLRgJKldHyV1z4e2e/dp9LPqCS3AM//uq/c3PnOFgjqnm5P+A==",
       "license": "MIT",
       "engines": {
         "node": ">=14.0"
@@ -4334,9 +4298,9 @@
       }
     },
     "node_modules/systeminformation": {
-      "version": "5.27.7",
-      "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.7.tgz",
-      "integrity": "sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==",
+      "version": "5.27.8",
+      "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.8.tgz",
+      "integrity": "sha512-d3Z0gaQO1MlUxzDUKsmXz5y4TOBCMZ8IyijzaYOykV3AcNOTQ7mT+tpndUOXYNSxzLK3la8G32xiUFvZ0/s6PA==",
       "license": "MIT",
       "optional": true,
       "os": [
@@ -4511,10 +4475,10 @@
       }
     },
     "node_modules/tslib": {
-      "version": "1.9.3",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz",
-      "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==",
-      "license": "Apache-2.0"
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
     },
     "node_modules/tv4": {
       "version": "1.3.0",

+ 1 - 0
oms/package.json

@@ -34,6 +34,7 @@
     "morgan": "^1.10.1",
     "node-cron": "^4.2.1",
     "pm2": "^6.0.8",
+    "punycode": "^2.3.1",
     "redis": "^5.8.1",
     "rotating-file-stream": "^3.2.6",
     "uuid": "^11.1.0"

+ 48 - 6
oms/services/event-api-service.ts

@@ -90,13 +90,55 @@ app.post("/napi/event/v2", async (req: Request, res: Response) => {
   }
 
   // 添加必要字段
-  eventData.ip_client = req.ip;
-  eventData.create_at = new Date();
-  eventData.local_country = req.header("x-country-code") || "nil";
-  // 在纯事件生产者服务中,通常更推荐使用如 UUID 等通用 ID。
+  eventData.ip = req.ip;
+  eventData.t = new Date();
+  eventData.cc = req.header("x-country-code") || "nil";
+  eventData._id = new mongoose.Types.ObjectId();
+
+  try {
+    const message = JSON.stringify(eventData);
+    // Publish the message to the exchange for broadcast
+    // persistent: true ensures the message survives RabbitMQ restarts
+    // '' as routing key for fanout exchange means it goes to all bound queues
+    const published = amqpChannel.publish(
+      RABBITMQ_EXCHANGE_NAME,
+      "", // Routing key (ignored by fanout exchange)
+      Buffer.from(message),
+      { persistent: true } // Mark message as persistent
+    );
+
+    if (published) {
+      // console.log('[Event API] Event published to RabbitMQ:', eventData); // Commented for high volume
+      res.status(200).json({ msg: "ok" });
+    } else {
+      // This case indicates the RabbitMQ buffer is full.
+      // In a real-world scenario, you might want to implement backpressure or retry.
+      console.warn("[Event API] Failed to publish event to RabbitMQ (channel full). Event:", eventData);
+      res.status(503).json({ message: "Service temporarily unavailable, please retry." });
+    }
+  } catch (error) {
+    console.error("[Event API] Error publishing event to RabbitMQ:", error);
+    res.status(500).json({ message: "Failed to process event." });
+  }
+});
+
+// --- API Endpoint: /napi/event/ ---
+app.post("/napi/event/", async (req: Request, res: Response) => {
+  if (!amqpChannel) {
+    console.error("[Event API] RabbitMQ channel not available.");
+    return res.status(500).json({ message: "Server is not ready to process events." });
+  }
+
+  const eventData = req.body;
+  if (!eventData || Object.keys(eventData).length === 0) {
+    return res.status(400).json({ message: "Event data is required." });
+  }
+
+  // 添加必要字段
+  eventData.ip = req.ip;
+  eventData.t = new Date();
+  eventData.cc = req.header("x-country-code") || "nil";
   eventData._id = new mongoose.Types.ObjectId();
-  // eventData._id = uuidv4(); // 👈 讨论:使用 uuidv4 生成唯一 ID
-  eventData.message_id = eventData._id;
 
   try {
     const message = JSON.stringify(eventData);

+ 7 - 0
oms/services/howto.md

@@ -6,3 +6,10 @@ curl -X POST \
      -d '{"appUsedMem":32,"bad_device":true,"type":"color_start","appMaxMem":512,"manufacturer":"samsung","low_device":true,"uid":"cf2N4mJ1RiK5lXzKuIpQ6TXXXX","project_id":1,"library_name":"android","from":"latest","model":"SM-A105M","lowMemory":false,"batteryCapacity":82,"deviceMem":1742,"res":"68541feae6830677ae9d818b","prod":"number","log_num":27,"version_code":343,"deviceAvailMem":308,"android_api":30,"version_name":"5.7.6","content_group":"builtin","days":1137,"position":117,"device":"a10","ip":"179.186.223.35","t":"2025-08-19T15:59:59.967Z","cc":"BR"}' \
      http://localhost:3001/napi/event/v2
 ```
+
+```
+curl -X POST \
+     -H "Content-Type: application/json" \
+     -d '{"appUsedMem":32,"bad_device":true,"type":"color_start","appMaxMem":512,"manufacturer":"samsung","low_device":true,"uid":"cf2N4mJ1RiK5lXzKuIpQ6TXXXX","project_id":1,"library_name":"android","from":"latest","model":"SM-A105M","lowMemory":false,"batteryCapacity":82,"deviceMem":1742,"res":"68541feae6830677ae9d818b","prod":"number","log_num":27,"version_code":343,"deviceAvailMem":308,"android_api":30,"version_name":"5.7.6","content_group":"builtin","days":1137,"position":117,"device":"a10","ip":"179.186.223.35","t":"2025-08-19T15:59:59.967Z","cc":"BR"}' \
+     http://touch.pcoloring.com/napi/event/v2
+```