Bläddra i källkod

新增道具使用次数统计

guoziyun 8 månader sedan
förälder
incheckning
1253a96847

+ 90 - 0
oms/dist/services/cron-jobs/backfill-done-rate-tip-count.js

@@ -0,0 +1,90 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+const dayjs_1 = __importDefault(require("dayjs"));
+const clients_1 = require("../../src/services/clients");
+const mongoose_1 = __importDefault(require("mongoose"));
+const doneRateModel_1 = __importDefault(require("../../src/models/doneRateModel"));
+// 导入 DoneRate 模型
+// ClickHouse 表名
+const CLICKHOUSE_EVENTS_TABLE = "events";
+// =========================================================================
+// !!! 必须修改这里以设置回填的起始日期 (YYYY-MM-DD 格式) !!!
+// 示例: 如果您的 tipCount 追踪是从 2024 年 1 月 1 日开始的,则设置为 "2024-01-01"
+const BACKFILL_START_DATE = "2025-01-03";
+/**
+ * 遍历指定日期范围,从 ClickHouse 提取 tipCount 并回填到 DoneRate 记录中。
+ * @returns Promise<string> - 返回回填结果的摘要信息。
+ */
+async function runBackfill() {
+    console.log(`[TipCount Backfill] Starting tipCount initialization for DoneRate model.`);
+    // 设置日期范围:从 BACKFILL_START_DATE 到昨天
+    let startDate = (0, dayjs_1.default)(BACKFILL_START_DATE).startOf("day");
+    const endDate = (0, dayjs_1.default)().subtract(1, "day").startOf("day");
+    if (!startDate.isValid() || startDate.isAfter(endDate)) {
+        const errorMsg = `[TipCount Backfill] Invalid start date ${BACKFILL_START_DATE} or date range is empty. Aborting.`;
+        console.error(errorMsg);
+        return errorMsg;
+    }
+    console.log(`[TipCount Backfill] Date Range: ${startDate.format("YYYY-MM-DD")} to ${endDate.format("YYYY-MM-DD")}`);
+    let totalDaysProcessed = 0;
+    let totalDoneRateUpdated = 0;
+    // 循环遍历历史数据中的每一天
+    for (let currentDay = startDate; currentDay.isBefore(endDate) || currentDay.isSame(endDate); currentDay = currentDay.add(1, "day")) {
+        const currentYYYYMMDD = currentDay.format("YYYYMMDD");
+        const currentStartString = currentDay.format("YYYY-MM-DD HH:mm:ss");
+        const currentEndString = currentDay.endOf("day").format("YYYY-MM-DD HH:mm:ss");
+        console.log(`\n--- [TipCount Backfill] Processing date: ${currentYYYYMMDD} ---`);
+        totalDaysProcessed++;
+        try {
+            // 1. 从 ClickHouse 中提取当天的 tipCount
+            const tipCountsQuery = `
+        SELECT
+            res,
+            count() AS tip_count
+        FROM ${CLICKHOUSE_EVENTS_TABLE}
+        WHERE event = 'color_tip'
+          AND time >= toDateTime('${currentStartString}')
+          AND time < toDateTime('${currentEndString}')
+        GROUP BY res
+        HAVING res IS NOT NULL
+      `;
+            const tipResults = await clients_1.clickhouseService.queryEvents(tipCountsQuery);
+            console.log(`[TipCount Backfill] Retrieved ${tipResults.length} records with 'color_tip' event.`);
+            if (tipResults.length === 0) {
+                // 如果当天没有 tip 事件,但可能存在 DoneRate 记录,我们仍然需要更新它们 (如果它们是 null 或 undefined)
+                // 但由于 DoneRateModel 默认 tipCount: 0,所以我们只需要处理有 tip 事件的记录。
+                console.log(`[TipCount Backfill] No 'color_tip' events found for ${currentYYYYMMDD}. Skipping MongoDB update.`);
+                continue;
+            }
+            // 2. 批量更新 MongoDB 中的 DoneRate 记录
+            const bulkOps = tipResults
+                .filter((row) => row.res && mongoose_1.default.Types.ObjectId.isValid(row.res)) // 过滤掉无效的 res ID
+                .map((row) => ({
+                updateOne: {
+                    // 查找条件:日期 + 作品ID (唯一复合索引)
+                    filter: { date: currentYYYYMMDD, res: new mongoose_1.default.Types.ObjectId(row.res) },
+                    // 更新操作:设置 tipCount 字段
+                    // 注意:我们只更新 tipCount,不影响 startCount 和 doneCount
+                    update: { $set: { tipCount: row.tip_count } },
+                    // 如果 DoneRate 记录不存在,则忽略。因为 DoneRate 记录通常由 color_start/color_done 事件创建。
+                    upsert: false,
+                },
+            }));
+            if (bulkOps.length > 0) {
+                const updateResult = await doneRateModel_1.default.bulkWrite(bulkOps, { ordered: false });
+                const modifiedCount = updateResult.modifiedCount || 0;
+                totalDoneRateUpdated += modifiedCount;
+                console.log(`[TipCount Backfill] Successfully updated ${modifiedCount} DoneRate documents for ${currentYYYYMMDD}.`);
+            }
+        }
+        catch (error) {
+            console.error(`[TipCount Backfill] Fatal error processing date ${currentYYYYMMDD}:`, error);
+        }
+    }
+    const summary = `[TipCount Backfill] DoneRate tipCount backfill completed. Total days processed: ${totalDaysProcessed}. Total DoneRate documents updated: ${totalDoneRateUpdated}.`;
+    console.log(`\n${summary}`);
+    return summary;
+}
+module.exports = { runBackfill };

+ 82 - 89
oms/dist/services/cron-jobs/done-rate.js

@@ -5,18 +5,15 @@ var __importDefault = (this && this.__importDefault) || function (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 clients_1 = require("../../src/services/clients");
 const mongoose_1 = __importDefault(require("mongoose")); // 导入 mongoose 和 Connection 用于处理远程连接
-const artModel_1 = __importDefault(require("../../src/models/artModel")); // 👈 导入 Art 模型和 IArt 接口
+const totalDoneRateModel_1 = __importDefault(require("../../src/models/totalDoneRateModel")); // 导入 TotalDoneRate 模型 (已包含 totalTipCount)
 // ClickHouse 表名
 const CLICKHOUSE_EVENTS_TABLE = "events"; // 确保与 ClickHouseService 中的表名一致
-// 远程数据库连接 URL
-const REMOTE_MONGO_URI = "mongodb://coloring:coloring123.@hk.jccytech.cn:7881/coloring_ol?authSource=admin";
 /**
  * 每日统计昨天的作品完成率。
- * 统计逻辑从 ClickHouse 中提取数据,得到每个作品的完成情况,并更新到 doneRateModel。
- * 随后,根据这些日统计数据,累加更新本地和远程的 Art 表的总统计字段
+ * 统计逻辑从 ClickHouse 中提取数据,得到每个作品的完成情况、道具使用情况,并更新到 doneRateModel。
+ * 随后,根据这些日统计数据,累加更新本地的 totalDoneRate 表 (包括 totalTipCount)
  * @returns Promise<string> - 返回统计结果的摘要信息。
  */
 async function run() {
@@ -30,11 +27,9 @@ async function run() {
     const yesterdayStartString = (0, dayjs_1.default)(yesterdayStart).format("YYYY-MM-DD HH:mm:ss");
     const yesterdayEndString = (0, dayjs_1.default)(yesterdayEnd).format("YYYY-MM-DD HH:mm:ss");
     console.log(`[DoneRate Cron] Processing data for date: ${yesterdayYYYYMMDD}`);
-    let remoteConn = null;
-    let updatedRemoteArtworksCount = 0;
     try {
-        // --- 1. 从 ClickHouse 中提取数据 ---
-        // 查询昨天每个作品的独立开始用户数
+        // --- 1. 从 ClickHouse 中提取数据 (Start, Done, Tip Counts) ---
+        // 1.1 查询昨天每个作品的独立开始用户数
         const startCountsQuery = `
       SELECT
           res,
@@ -57,7 +52,7 @@ async function run() {
             }
         });
         console.log(`[DoneRate Cron] Retrieved ${startResults.length} unique start counts from ClickHouse.`);
-        // 查询昨天每个作品的独立完成用户数
+        // 1.2 查询昨天每个作品的独立完成用户数
         const doneCountsQuery = `
       SELECT
           res,
@@ -80,28 +75,41 @@ async function run() {
             }
         });
         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++;
+        // 1.3 查询昨天每个作品的使用道具数
+        const tipCountsQuery = `
+      SELECT
+          res,
+          count() AS tip_count
+      FROM ${CLICKHOUSE_EVENTS_TABLE}
+      WHERE event = 'color_tip'
+        AND time >= toDateTime('${yesterdayStartString}')
+        AND time < toDateTime('${yesterdayEndString}')
+      GROUP BY res
+      HAVING res IS NOT NULL
+    `;
+        const tipResults = await clients_1.clickhouseService.queryEvents(tipCountsQuery);
+        const artworkTipCounts = new Map();
+        tipResults.forEach((row) => {
+            if (row.res && mongoose_1.default.Types.ObjectId.isValid(row.res)) {
+                artworkTipCounts.set(row.res, row.tip_count);
             }
             else {
-                updatedRecordsCount++;
+                console.warn(`[DoneRate Cron] Invalid artwork ID found in tip_counts result: ${row.res}`);
             }
-            artworkDoneCounts.delete(resIdStr); // 已经处理过的作品ID从 doneCounts 中移除
-        }
-        // 处理只有完成事件但没有开始事件的作品 (通常不应发生,但以防万一)
-        for (const [resIdStr, doneCount] of artworkDoneCounts.entries()) {
-            const startCount = 0; // 没有开始事件,所以开始次数为0
+        });
+        console.log(`[DoneRate Cron] Retrieved ${tipResults.length} unique tip counts from ClickHouse.`);
+        // --- 2. 合并数据并更新 DoneRate 模型 (每日记录) ---
+        let updatedRecordsCount = 0;
+        let createdRecordsCount = 0;
+        // 获取所有需要处理的作品 ID 集合 (Start + Done + Tip)
+        const allResIds = new Set([...artworkStartCounts.keys(), ...artworkDoneCounts.keys(), ...artworkTipCounts.keys()]);
+        for (const resIdStr of allResIds.values()) {
+            const startCount = artworkStartCounts.get(resIdStr) || 0;
+            const doneCount = artworkDoneCounts.get(resIdStr) || 0;
+            const tipCount = artworkTipCounts.get(resIdStr) || 0;
             const resObjectId = new mongoose_1.default.Types.ObjectId(resIdStr);
-            const doneRateDoc = await doneRateService_1.default.createOrUpdateDoneRate(yesterdayYYYYMMDD, resObjectId, startCount, doneCount);
+            // 使用 DoneRateService 来创建或更新记录,并传入 tipCount
+            const doneRateDoc = await doneRateService_1.default.createOrUpdateDoneRate(yesterdayYYYYMMDD, resObjectId, startCount, doneCount, tipCount);
             if (doneRateDoc.createdAt.getTime() === doneRateDoc.updatedAt.getTime()) {
                 createdRecordsCount++;
             }
@@ -110,73 +118,63 @@ async function run() {
             }
         }
         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
+        console.log(`[DoneRate Cron] DoneRate model (Daily) update completed. Total artworks processed: ${totalProcessedArtworks}. Created: ${createdRecordsCount}, Updated: ${updatedRecordsCount}.`);
+        // --- 3. 获取昨天的 DoneRate 记录,并更新本地的 TotalDoneRate 表 (累计记录) ---
+        let updatedTotalDoneRateCount = 0;
+        // 仅获取昨天更新或创建的记录,以保证数据源为最新
         const yesterdayDoneRates = await doneRateService_1.default.getDoneRatesByDate(yesterdayYYYYMMDD);
-        console.log(`[DoneRate Cron] Found ${yesterdayDoneRates.length} DoneRate records for yesterday to update Art table.`);
-        // ============= 新增日志和进度追踪逻辑 =============
-        const totalDoneRates = yesterdayDoneRates.length;
-        let remoteUpdateStartTime = new Date().getTime();
-        console.log(`[DoneRate Cron] 开始更新远程 Art 表。总计 ${totalDoneRates} 条记录。`);
-        // ===================================================
-        for (let i = 0; i < totalDoneRates; i++) {
+        const totalDoneRatesToUpdate = yesterdayDoneRates.length;
+        let updateStartTime = new Date().getTime();
+        console.log(`[DoneRate Cron] 开始更新本地 TotalDoneRate 表。总计 ${totalDoneRatesToUpdate} 条记录。`);
+        for (let i = 0; i < totalDoneRatesToUpdate; i++) {
             const doneRateDoc = yesterdayDoneRates[i];
             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 文档
-                    await artService_1.default.updateArt(artworkId.toString(), {
+                // 查找现有的 TotalDoneRate 文档
+                const existingTotal = await totalDoneRateModel_1.default.findById(artworkId).lean().exec();
+                // 1. 初始化昨天的计数
+                const dailyStartCount = doneRateDoc.startCount;
+                const dailyDoneCount = doneRateDoc.doneCount;
+                const dailyTipCount = doneRateDoc.tipCount; // 使用昨天的道具使用次数
+                // 2. 计算累计总数
+                let newTotalStartCount = dailyStartCount;
+                let newTotalDoneCount = dailyDoneCount;
+                let newTotalTipCount = dailyTipCount; // 累计总道具使用次数
+                if (existingTotal) {
+                    // 如果已存在,则累加旧的总数
+                    // 注意:假设此 cron job 每天只运行一次,否则需要更复杂的幂等逻辑
+                    newTotalStartCount += existingTotal.totalStartCount || 0;
+                    newTotalDoneCount += existingTotal.totalDoneCount || 0;
+                    newTotalTipCount += existingTotal.totalTipCount || 0; // 累加 totalTipCount
+                }
+                // 3. 计算总完成率
+                const newCompletionRate = newTotalStartCount > 0 ? (newTotalDoneCount / newTotalStartCount) * 100 : 0;
+                // 4. 使用 findByIdAndUpdate 进行原子操作
+                const updatedDoc = await totalDoneRateModel_1.default.findByIdAndUpdate(artworkId, {
+                    $set: {
                         totalStartCount: newTotalStartCount,
                         totalDoneCount: newTotalDoneCount,
+                        totalTipCount: newTotalTipCount, // 写入新的累计道具使用次数
                         completionRate: newCompletionRate,
-                    });
-                    updatedLocalArtworksCount++;
-                    // 同步更新远程 Art 文档
-                    const remoteUpdateResult = await RemoteArt.findByIdAndUpdate(artworkId, {
-                        $set: {
-                            totalStartCount: newTotalStartCount,
-                            totalDoneCount: newTotalDoneCount,
-                            completionRate: newCompletionRate,
-                        },
-                    }, { new: true } // 返回更新后的文档
-                    );
-                    if (remoteUpdateResult) {
-                        updatedRemoteArtworksCount++;
-                    }
-                    else {
-                        console.warn(`[DoneRate Cron] Remote Art document with ID ${artworkId} not found. Skipping remote update.`);
-                    }
-                    // ============= 新增进度日志 =============
-                    if ((i + 1) % 50 === 0 || i === totalDoneRates - 1) {
-                        const elapsed = new Date().getTime() - remoteUpdateStartTime;
-                        console.log(`[DoneRate Cron] 进度: 已更新 ${i + 1}/${totalDoneRates} 条远程 Art 记录, 当前耗时: ${elapsed}ms`);
-                    }
-                    // ==========================================
+                    },
+                }, { new: true, upsert: true } // upsert: true 表示如果文档不存在则创建
+                );
+                if (updatedDoc) {
+                    updatedTotalDoneRateCount++;
                 }
-                else {
-                    console.warn(`[DoneRate Cron] Local Art document with ID ${artworkId} not found for DoneRate record (date: ${doneRateDoc.date}). Skipping Art update.`);
+                // 进度日志
+                if ((i + 1) % 50 === 0 || i === totalDoneRatesToUpdate - 1) {
+                    const elapsed = new Date().getTime() - updateStartTime;
+                    console.log(`[DoneRate Cron] 进度: 已更新 ${i + 1}/${totalDoneRatesToUpdate} 条本地 TotalDoneRate 记录, 当前耗时: ${elapsed}ms`);
                 }
             }
-            catch (artUpdateError) {
-                console.error(`[DoneRate Cron] Error updating Art document for artwork ID ${doneRateDoc.res}:`, artUpdateError);
+            catch (totalUpdateError) {
+                console.error(`[DoneRate Cron] Error updating TotalDoneRate document for artwork ID ${doneRateDoc.res}:`, totalUpdateError);
             }
         }
-        // ============= 新增总结日志 =============
-        const remoteUpdateTimeTaken = new Date().getTime() - remoteUpdateStartTime;
-        console.log(`[DoneRate Cron] 远程 Art 表更新完成。总计更新 ${updatedRemoteArtworksCount} 条记录,总耗时 ${remoteUpdateTimeTaken}ms。`);
-        // ========================================
-        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}.`;
+        const updateTimeTaken = new Date().getTime() - updateStartTime;
+        console.log(`[DoneRate Cron] 本地 TotalDoneRate 表更新完成。总计更新 ${updatedTotalDoneRateCount} 条记录,总耗时 ${updateTimeTaken}ms。`);
+        const summary = `[DoneRate Cron] Daily done-rate calculation for ${yesterdayYYYYMMDD} completed. Total DoneRate processed: ${totalProcessedArtworks}. Created DoneRate: ${createdRecordsCount}, Updated DoneRate: ${updatedRecordsCount}. Updated TotalDoneRate records: ${updatedTotalDoneRateCount}.`;
         console.log(summary);
         return summary;
     }
@@ -185,11 +183,6 @@ async function run() {
         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 };

+ 1 - 1
oms/dist/services/cron-jobs/index.js

@@ -11,7 +11,7 @@ const clients_1 = require("../../src/services/clients"); // 从新的文件导
 // Each element: [name: string, schedule: string, jobModule: CronJobModule]
 const settings = [
     // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
-    ["done-rate", "10 0 * * *", require("./done-rate2")], // 每天凌晨0点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") as CronJobModule], // 每5分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了
     // ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify") as CronJobModule], // 每天下午6点,开始活跃用户新作品消息推送

+ 39 - 0
oms/dist/services/cron-jobs/initialize-tip-count.js

@@ -0,0 +1,39 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+const doneRateModel_1 = __importDefault(require("../../src/models/doneRateModel"));
+/**
+ * 初始化所有 DoneRate 历史文档中缺失的 tipCount 字段为 0。
+ * 确保所有文档在开始回填前拥有 tipCount 字段,以保证数据一致性。
+ * @returns Promise<string> - 返回初始化结果的摘要信息。
+ */
+async function runInitialization() {
+    console.log("[TipCount Initialization] Starting to set missing tipCount field to 0 for all historical DoneRate documents...");
+    const startTime = Date.now();
+    try {
+        // 查找条件: 查找 tipCount 字段不存在的文档 (即历史数据)
+        const filter = { tipCount: { $exists: false } };
+        // 更新操作: 将 tipCount 设置为 0
+        const update = { $set: { tipCount: 0 } };
+        // 选项: 更新所有匹配的文档
+        const options = { multi: true };
+        const result = await doneRateModel_1.default.updateMany(filter, update, options).exec();
+        const endTime = Date.now();
+        const timeTaken = ((endTime - startTime) / 1000).toFixed(2);
+        if (result.modifiedCount === 0) {
+            console.log("[TipCount Initialization] No missing tipCount fields found. Historical data seems consistent or already initialized.");
+        }
+        else {
+            console.log(`[TipCount Initialization] Successfully initialized ${result.modifiedCount} historical DoneRate documents to tipCount: 0.`);
+        }
+        const summary = `[TipCount Initialization] Initialization completed in ${timeTaken} seconds. Total documents modified: ${result.modifiedCount}.`;
+        console.log(`\n${summary}`);
+        return summary;
+    }
+    catch (error) {
+        console.error("[TipCount Initialization] Fatal error during initialization:", error);
+        throw new Error("Failed to initialize DoneRate tipCount field.");
+    }
+}
+module.exports = { runInitialization };

+ 88 - 0
oms/dist/services/cron-jobs/recalculate-total-done-rate.js

@@ -0,0 +1,88 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+const doneRateModel_1 = __importDefault(require("../../src/models/doneRateModel"));
+const totalDoneRateModel_1 = __importDefault(require("../../src/models/totalDoneRateModel"));
+/**
+ * 重新计算 TotalDoneRate 表中的所有累计字段。
+ * 通过聚合 DoneRate 表中的所有历史数据来实现。
+ * 此脚本应在 DoneRate 表的 tipCount 字段回填完成后运行。
+ * @returns Promise<string> - 返回累计更新结果的摘要信息。
+ */
+async function runRecalculation() {
+    console.log("[TotalDoneRate Recalculation] Starting full recalculation of TotalDoneRate table...");
+    const startTime = Date.now();
+    try {
+        // 1. 使用聚合操作,从 DoneRate 表中计算每个作品的总累计数据
+        console.log("[TotalDoneRate Recalculation] Step 1: Aggregating all DoneRate records...");
+        // 聚合管道定义
+        const aggregationPipeline = [
+            {
+                // 1. 按作品 ID (res) 分组
+                $group: {
+                    _id: "$res", // 使用 res 作为分组键
+                    totalStartCount: { $sum: "$startCount" },
+                    totalDoneCount: { $sum: "$doneCount" },
+                    totalTipCount: { $sum: "$tipCount" }, // 累加 tipCount
+                },
+            },
+            {
+                // 2. 计算最终的 completionRate
+                $project: {
+                    _id: "$_id",
+                    totalStartCount: "$totalStartCount",
+                    totalDoneCount: "$totalDoneCount",
+                    totalTipCount: "$totalTipCount",
+                    completionRate: {
+                        $cond: [
+                            { $gt: ["$totalStartCount", 0] },
+                            { $multiply: [{ $divide: ["$totalDoneCount", "$totalStartCount"] }, 100] },
+                            0, // 如果 totalStartCount 为 0,则 completionRate 为 0
+                        ],
+                    },
+                },
+            },
+        ];
+        const aggregatedResults = await doneRateModel_1.default.aggregate(aggregationPipeline).exec();
+        console.log(`[TotalDoneRate Recalculation] Aggregation complete. Found ${aggregatedResults.length} unique artworks to update.`);
+        // 2. 批量更新 TotalDoneRate 表
+        const bulkOps = aggregatedResults.map((result) => ({
+            updateOne: {
+                // 查找条件:使用聚合结果的 _id (即作品 ObjectId)
+                filter: { _id: result._id },
+                // 更新操作:设置所有累计字段
+                update: {
+                    $set: {
+                        totalStartCount: result.totalStartCount,
+                        totalDoneCount: result.totalDoneCount,
+                        totalTipCount: result.totalTipCount, // 设置重新计算的总道具使用数
+                        completionRate: result.completionRate,
+                    },
+                },
+                // 如果 TotalDoneRate 记录不存在,则创建新记录
+                upsert: true,
+            },
+        }));
+        if (bulkOps.length > 0) {
+            console.log(`[TotalDoneRate Recalculation] Step 2: Running bulk update for ${bulkOps.length} documents...`);
+            const updateResult = await totalDoneRateModel_1.default.bulkWrite(bulkOps, { ordered: false });
+            const modifiedCount = (updateResult.modifiedCount || 0) + (updateResult.upsertedCount || 0);
+            const endTime = Date.now();
+            const timeTaken = ((endTime - startTime) / 1000).toFixed(2);
+            const summary = `[TotalDoneRate Recalculation] Full recalculation completed in ${timeTaken} seconds. Total artworks updated/created: ${modifiedCount}.`;
+            console.log(`\n${summary}`);
+            return summary;
+        }
+        else {
+            const summary = "[TotalDoneRate Recalculation] Aggregation returned no results. TotalDoneRate table update skipped.";
+            console.log(summary);
+            return summary;
+        }
+    }
+    catch (error) {
+        console.error("[TotalDoneRate Recalculation] Fatal error during full recalculation:", error);
+        throw new Error("Failed to recalculate TotalDoneRate history.");
+    }
+}
+module.exports = { runRecalculation };

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

@@ -61,6 +61,12 @@ const DoneRateSchema = new mongoose_1.Schema({
         default: 0,
         min: 0, // 确保完成次数不为负
     },
+    tipCount: {
+        type: Number,
+        required: true,
+        default: 0,
+        min: 0, // 确保道具使用次数不为负
+    },
     completionRate: {
         type: Number,
         required: true,

+ 5 - 0
oms/dist/src/models/totalDoneRateModel.js

@@ -48,6 +48,11 @@ const totalDoneRateSchema = new mongoose_1.Schema({
         required: true,
         default: 0,
     },
+    totalTipCount: {
+        type: Number,
+        required: true,
+        default: 0,
+    },
     completionRate: {
         type: Number,
         required: true,

+ 20 - 13
oms/dist/src/services/doneRateService.js

@@ -15,12 +15,15 @@ class DoneRateService {
      * @param resId - 作品 ID (mongoose.Types.ObjectId)。
      * @param startCount - 今日该作品点击进入填色的次数。
      * @param doneCount - 今日该作品的完成数。
+     * @param tipCount - 今日该作品使用道具数。 <--- 新增参数
      * @returns 创建或更新后的 DoneRate 文档。
      */
-    async createOrUpdateDoneRate(date, resId, startCount, doneCount) {
+    async createOrUpdateDoneRate(date, resId, startCount, doneCount, tipCount // <--- 接收 tipCount
+    ) {
         try {
-            if (startCount < 0 || doneCount < 0) {
-                throw new Error("startCount and doneCount cannot be negative.");
+            if (startCount < 0 || doneCount < 0 || tipCount < 0) {
+                // <--- 增加 tipCount 校验
+                throw new Error("startCount, doneCount, and tipCount cannot be negative.");
             }
             // 计算完成率
             const completionRate = startCount > 0 ? (doneCount / startCount) * 100 : 0;
@@ -29,6 +32,7 @@ class DoneRateService {
                 $set: {
                     startCount: startCount,
                     doneCount: doneCount,
+                    tipCount: tipCount, // <--- 更新 tipCount
                     completionRate: completionRate,
                 },
                 $setOnInsert: {
@@ -101,21 +105,24 @@ class DoneRateService {
             delete mutableUpdateData.date;
             delete mutableUpdateData.res;
             // 如果提供了 startCount 或 doneCount,重新计算 completionRate
-            if ((mutableUpdateData.startCount !== undefined && mutableUpdateData.startCount >= 0) || (mutableUpdateData.doneCount !== undefined && mutableUpdateData.doneCount >= 0)) {
+            // 检查是否传入了任何需要重新计算完成率的字段
+            const shouldRecalculateRate = (mutableUpdateData.startCount !== undefined && mutableUpdateData.startCount >= 0) || (mutableUpdateData.doneCount !== undefined && mutableUpdateData.doneCount >= 0);
+            if (shouldRecalculateRate) {
                 // 先获取现有记录,以计算正确的完成率
                 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;
+                let currentStartCount = existingRecord ? existingRecord.startCount : 0;
+                let currentDoneCount = existingRecord ? existingRecord.doneCount : 0;
+                // 应用传入的更新值
+                if (mutableUpdateData.startCount !== undefined) {
+                    currentStartCount = mutableUpdateData.startCount;
                 }
-                else {
-                    // 如果记录不存在,则尝试更新时,完成率应基于传入数据或默认0
-                    const currentStartCount = mutableUpdateData.startCount || 0;
-                    const currentDoneCount = mutableUpdateData.doneCount || 0;
-                    mutableUpdateData.completionRate = currentStartCount > 0 ? (currentDoneCount / currentStartCount) * 100 : 0;
+                if (mutableUpdateData.doneCount !== undefined) {
+                    currentDoneCount = mutableUpdateData.doneCount;
                 }
+                // 计算新的完成率
+                mutableUpdateData.completionRate = currentStartCount > 0 ? (currentDoneCount / currentStartCount) * 100 : 0;
             }
+            // 注意:tipCount 不需要特殊处理,它直接作为可更新字段包含在 mutableUpdateData 中
             const updatedDoneRate = await doneRateModel_1.default.findOneAndUpdate({ date, res: resId }, { $set: mutableUpdateData }, { new: true, runValidators: true } // 返回更新后的文档,并运行 Schema 验证器
             );
             return updatedDoneRate;

+ 112 - 0
oms/services/cron-jobs/backfill-done-rate-tip-count.ts

@@ -0,0 +1,112 @@
+import dayjs from "dayjs";
+import { clickhouseService } from "../../src/services/clients";
+import mongoose, { Model } from "mongoose";
+import DoneRate, { IDoneRate } from "../../src/models/doneRateModel";
+// 导入 DoneRate 模型
+
+// ClickHouse 表名
+const CLICKHOUSE_EVENTS_TABLE = "events";
+
+// =========================================================================
+// !!! 必须修改这里以设置回填的起始日期 (YYYY-MM-DD 格式) !!!
+// 示例: 如果您的 tipCount 追踪是从 2024 年 1 月 1 日开始的,则设置为 "2024-01-01"
+const BACKFILL_START_DATE = "2025-01-03";
+// =========================================================================
+
+/**
+ * ClickHouse 查询结果接口:每日每个作品的使用道具数 (tipCount)
+ */
+interface ClickHouseTipCountResult {
+  res: string; // 作品 ID (ObjectId 字符串形式)
+  tip_count: number; // 道具使用次数
+}
+
+/**
+ * 遍历指定日期范围,从 ClickHouse 提取 tipCount 并回填到 DoneRate 记录中。
+ * @returns Promise<string> - 返回回填结果的摘要信息。
+ */
+async function runBackfill(): Promise<string> {
+  console.log(`[TipCount Backfill] Starting tipCount initialization for DoneRate model.`);
+
+  // 设置日期范围:从 BACKFILL_START_DATE 到昨天
+  let startDate = dayjs(BACKFILL_START_DATE).startOf("day");
+  const endDate = dayjs().subtract(1, "day").startOf("day");
+
+  if (!startDate.isValid() || startDate.isAfter(endDate)) {
+    const errorMsg = `[TipCount Backfill] Invalid start date ${BACKFILL_START_DATE} or date range is empty. Aborting.`;
+    console.error(errorMsg);
+    return errorMsg;
+  }
+
+  console.log(`[TipCount Backfill] Date Range: ${startDate.format("YYYY-MM-DD")} to ${endDate.format("YYYY-MM-DD")}`);
+
+  let totalDaysProcessed = 0;
+  let totalDoneRateUpdated = 0;
+
+  // 循环遍历历史数据中的每一天
+  for (let currentDay = startDate; currentDay.isBefore(endDate) || currentDay.isSame(endDate); currentDay = currentDay.add(1, "day")) {
+    const currentYYYYMMDD = currentDay.format("YYYYMMDD");
+    const currentStartString = currentDay.format("YYYY-MM-DD HH:mm:ss");
+    const currentEndString = currentDay.endOf("day").format("YYYY-MM-DD HH:mm:ss");
+
+    console.log(`\n--- [TipCount Backfill] Processing date: ${currentYYYYMMDD} ---`);
+    totalDaysProcessed++;
+
+    try {
+      // 1. 从 ClickHouse 中提取当天的 tipCount
+      const tipCountsQuery = `
+        SELECT
+            res,
+            count() AS tip_count
+        FROM ${CLICKHOUSE_EVENTS_TABLE}
+        WHERE event = 'color_tip'
+          AND time >= toDateTime('${currentStartString}')
+          AND time < toDateTime('${currentEndString}')
+        GROUP BY res
+        HAVING res IS NOT NULL
+      `;
+      const tipResults = await clickhouseService.queryEvents<ClickHouseTipCountResult>(tipCountsQuery);
+
+      console.log(`[TipCount Backfill] Retrieved ${tipResults.length} records with 'color_tip' event.`);
+
+      if (tipResults.length === 0) {
+        // 如果当天没有 tip 事件,但可能存在 DoneRate 记录,我们仍然需要更新它们 (如果它们是 null 或 undefined)
+        // 但由于 DoneRateModel 默认 tipCount: 0,所以我们只需要处理有 tip 事件的记录。
+        console.log(`[TipCount Backfill] No 'color_tip' events found for ${currentYYYYMMDD}. Skipping MongoDB update.`);
+        continue;
+      }
+
+      // 2. 批量更新 MongoDB 中的 DoneRate 记录
+      const bulkOps = tipResults
+        .filter((row) => row.res && mongoose.Types.ObjectId.isValid(row.res)) // 过滤掉无效的 res ID
+        .map((row) => ({
+          updateOne: {
+            // 查找条件:日期 + 作品ID (唯一复合索引)
+            filter: { date: currentYYYYMMDD, res: new mongoose.Types.ObjectId(row.res) },
+            // 更新操作:设置 tipCount 字段
+            // 注意:我们只更新 tipCount,不影响 startCount 和 doneCount
+            update: { $set: { tipCount: row.tip_count } },
+            // 如果 DoneRate 记录不存在,则忽略。因为 DoneRate 记录通常由 color_start/color_done 事件创建。
+            upsert: false,
+          },
+        }));
+
+      if (bulkOps.length > 0) {
+        const updateResult = await DoneRate.bulkWrite(bulkOps, { ordered: false });
+
+        const modifiedCount = updateResult.modifiedCount || 0;
+        totalDoneRateUpdated += modifiedCount;
+
+        console.log(`[TipCount Backfill] Successfully updated ${modifiedCount} DoneRate documents for ${currentYYYYMMDD}.`);
+      }
+    } catch (error) {
+      console.error(`[TipCount Backfill] Fatal error processing date ${currentYYYYMMDD}:`, error);
+    }
+  }
+
+  const summary = `[TipCount Backfill] DoneRate tipCount backfill completed. Total days processed: ${totalDaysProcessed}. Total DoneRate documents updated: ${totalDoneRateUpdated}.`;
+  console.log(`\n${summary}`);
+  return summary;
+}
+
+export = { runBackfill };

+ 106 - 104
oms/services/cron-jobs/done-rate.ts

@@ -2,17 +2,13 @@
 
 import dayjs from "dayjs";
 import doneRateService from "../../src/services/doneRateService"; // 导入 DoneRateService
-import artService from "../../src/services/artService"; // 👈 导入 ArtService
 import { clickhouseService } from "../../src/services/clients";
 import mongoose, { Connection } from "mongoose"; // 导入 mongoose 和 Connection 用于处理远程连接
-import Art, { IArt } from "../../src/models/artModel"; // 👈 导入 Art 模型和 IArt 接口
+import TotalDoneRate from "../../src/models/totalDoneRateModel"; // 导入 TotalDoneRate 模型 (已包含 totalTipCount)
 
 // ClickHouse 表名
 const CLICKHOUSE_EVENTS_TABLE = "events"; // 确保与 ClickHouseService 中的表名一致
 
-// 远程数据库连接 URL
-const REMOTE_MONGO_URI = "mongodb://coloring:coloring123.@hk.jccytech.cn:7881/coloring_ol?authSource=admin";
-
 /**
  * ClickHouse 查询结果接口:每日每个作品的独立开始用户数
  */
@@ -29,10 +25,18 @@ interface ClickHouseDoneCountResult {
   unique_dones: number; // 独立完成用户数
 }
 
+/**
+ * ClickHouse 查询结果接口:每日每个作品的使用道具数 (对应 tipCount)
+ */
+interface ClickHouseTipCountResult {
+  res: string; // 作品 ID
+  tip_count: number; // 道具使用次数
+}
+
 /**
  * 每日统计昨天的作品完成率。
- * 统计逻辑从 ClickHouse 中提取数据,得到每个作品的完成情况,并更新到 doneRateModel。
- * 随后,根据这些日统计数据,累加更新本地和远程的 Art 表的总统计字段。
+ * 统计逻辑从 ClickHouse 中提取数据,得到每个作品的完成情况、道具使用情况,并更新到 doneRateModel。
+ * 随后,根据这些日统计数据,累加更新本地的 totalDoneRate 表 (包括 totalTipCount)
  * @returns Promise<string> - 返回统计结果的摘要信息。
  */
 async function run(): Promise<string> {
@@ -50,13 +54,10 @@ async function run(): Promise<string> {
 
   console.log(`[DoneRate Cron] Processing data for date: ${yesterdayYYYYMMDD}`);
 
-  let remoteConn: Connection | null = null;
-  let updatedRemoteArtworksCount = 0;
-
   try {
-    // --- 1. 从 ClickHouse 中提取数据 ---
+    // --- 1. 从 ClickHouse 中提取数据 (Start, Done, Tip Counts) ---
 
-    // 查询昨天每个作品的独立开始用户数
+    // 1.1 查询昨天每个作品的独立开始用户数
     const startCountsQuery = `
       SELECT
           res,
@@ -79,7 +80,7 @@ async function run(): Promise<string> {
     });
     console.log(`[DoneRate Cron] Retrieved ${startResults.length} unique start counts from ClickHouse.`);
 
-    // 查询昨天每个作品的独立完成用户数
+    // 1.2 查询昨天每个作品的独立完成用户数
     const doneCountsQuery = `
       SELECT
           res,
@@ -102,31 +103,44 @@ async function run(): Promise<string> {
     });
     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.Types.ObjectId(resIdStr);
-
-      // 使用 DoneRateService 来创建或更新记录
-      const doneRateDoc = await doneRateService.createOrUpdateDoneRate(yesterdayYYYYMMDD, resObjectId, startCount, doneCount);
-      if (doneRateDoc.createdAt.getTime() === doneRateDoc.updatedAt.getTime()) {
-        createdRecordsCount++;
+    // 1.3 查询昨天每个作品的使用道具数
+    const tipCountsQuery = `
+      SELECT
+          res,
+          count() AS tip_count
+      FROM ${CLICKHOUSE_EVENTS_TABLE}
+      WHERE event = 'color_tip'
+        AND time >= toDateTime('${yesterdayStartString}')
+        AND time < toDateTime('${yesterdayEndString}')
+      GROUP BY res
+      HAVING res IS NOT NULL
+    `;
+    const tipResults = await clickhouseService.queryEvents<ClickHouseTipCountResult>(tipCountsQuery);
+    const artworkTipCounts = new Map<string, number>();
+    tipResults.forEach((row) => {
+      if (row.res && mongoose.Types.ObjectId.isValid(row.res)) {
+        artworkTipCounts.set(row.res, row.tip_count);
       } else {
-        updatedRecordsCount++;
+        console.warn(`[DoneRate Cron] Invalid artwork ID found in tip_counts result: ${row.res}`);
       }
-      artworkDoneCounts.delete(resIdStr); // 已经处理过的作品ID从 doneCounts 中移除
-    }
+    });
+    console.log(`[DoneRate Cron] Retrieved ${tipResults.length} unique tip counts from ClickHouse.`);
+
+    // --- 2. 合并数据并更新 DoneRate 模型 (每日记录) ---
+    let updatedRecordsCount = 0;
+    let createdRecordsCount = 0;
+
+    // 获取所有需要处理的作品 ID 集合 (Start + Done + Tip)
+    const allResIds = new Set([...artworkStartCounts.keys(), ...artworkDoneCounts.keys(), ...artworkTipCounts.keys()]);
 
-    // 处理只有完成事件但没有开始事件的作品 (通常不应发生,但以防万一)
-    for (const [resIdStr, doneCount] of artworkDoneCounts.entries()) {
-      const startCount = 0; // 没有开始事件,所以开始次数为0
+    for (const resIdStr of allResIds.values()) {
+      const startCount = artworkStartCounts.get(resIdStr) || 0;
+      const doneCount = artworkDoneCounts.get(resIdStr) || 0;
+      const tipCount = artworkTipCounts.get(resIdStr) || 0;
       const resObjectId = new mongoose.Types.ObjectId(resIdStr);
 
-      const doneRateDoc = await doneRateService.createOrUpdateDoneRate(yesterdayYYYYMMDD, resObjectId, startCount, doneCount);
+      // 使用 DoneRateService 来创建或更新记录,并传入 tipCount
+      const doneRateDoc = await doneRateService.createOrUpdateDoneRate(yesterdayYYYYMMDD, resObjectId, startCount, doneCount, tipCount);
       if (doneRateDoc.createdAt.getTime() === doneRateDoc.updatedAt.getTime()) {
         createdRecordsCount++;
       } else {
@@ -136,97 +150,85 @@ async function run(): Promise<string> {
 
     const totalProcessedArtworks = createdRecordsCount + updatedRecordsCount;
 
-    console.log(`[DoneRate Cron] DoneRate model update completed. Total artworks processed: ${totalProcessedArtworks}. Created: ${createdRecordsCount}, Updated: ${updatedRecordsCount}.`);
-
-    // --- 3. 获取昨天的所有 DoneRate 记录,并更新本地和远程的 Art 表 ---
+    console.log(`[DoneRate Cron] DoneRate model (Daily) update completed. Total artworks processed: ${totalProcessedArtworks}. Created: ${createdRecordsCount}, Updated: ${updatedRecordsCount}.`);
 
-    // 建立远程数据库连接和模型
-    remoteConn = await mongoose.createConnection(REMOTE_MONGO_URI);
-    const RemoteArt = remoteConn.model<IArt>("Art", Art.schema);
-    console.log(`[DoneRate Cron] Connected to remote database.`);
+    // --- 3. 获取昨天的 DoneRate 记录,并更新本地的 TotalDoneRate 表 (累计记录) ---
 
-    let updatedLocalArtworksCount = 0; // for Art model
+    let updatedTotalDoneRateCount = 0;
+    // 仅获取昨天更新或创建的记录,以保证数据源为最新
     const yesterdayDoneRates = await doneRateService.getDoneRatesByDate(yesterdayYYYYMMDD);
-    console.log(`[DoneRate Cron] Found ${yesterdayDoneRates.length} DoneRate records for yesterday to update Art table.`);
 
-    // ============= 新增日志和进度追踪逻辑 =============
-    const totalDoneRates = yesterdayDoneRates.length;
-    let remoteUpdateStartTime = new Date().getTime();
-    console.log(`[DoneRate Cron] 开始更新远程 Art 表。总计 ${totalDoneRates} 条记录。`);
-    // ===================================================
+    const totalDoneRatesToUpdate = yesterdayDoneRates.length;
+    let updateStartTime = new Date().getTime();
+    console.log(`[DoneRate Cron] 开始更新本地 TotalDoneRate 表。总计 ${totalDoneRatesToUpdate} 条记录。`);
 
-    for (let i = 0; i < totalDoneRates; i++) {
+    for (let i = 0; i < totalDoneRatesToUpdate; i++) {
       const doneRateDoc = yesterdayDoneRates[i];
       try {
         const artworkId = doneRateDoc.res; // 获取作品 ObjectId
-        const currentArt = await artService.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 文档
-          await artService.updateArt(artworkId.toString(), {
-            totalStartCount: newTotalStartCount,
-            totalDoneCount: newTotalDoneCount,
-            completionRate: newCompletionRate,
-          });
-          updatedLocalArtworksCount++;
-
-          // 同步更新远程 Art 文档
-          const remoteUpdateResult = await RemoteArt.findByIdAndUpdate(
-            artworkId,
-            {
-              $set: {
-                totalStartCount: newTotalStartCount,
-                totalDoneCount: newTotalDoneCount,
-                completionRate: newCompletionRate,
-              },
+
+        // 查找现有的 TotalDoneRate 文档
+        const existingTotal = await TotalDoneRate.findById(artworkId).lean().exec();
+
+        // 1. 初始化昨天的计数
+        const dailyStartCount = doneRateDoc.startCount;
+        const dailyDoneCount = doneRateDoc.doneCount;
+        const dailyTipCount = doneRateDoc.tipCount; // 使用昨天的道具使用次数
+
+        // 2. 计算累计总数
+        let newTotalStartCount = dailyStartCount;
+        let newTotalDoneCount = dailyDoneCount;
+        let newTotalTipCount = dailyTipCount; // 累计总道具使用次数
+
+        if (existingTotal) {
+          // 如果已存在,则累加旧的总数
+          // 注意:假设此 cron job 每天只运行一次,否则需要更复杂的幂等逻辑
+          newTotalStartCount += existingTotal.totalStartCount || 0;
+          newTotalDoneCount += existingTotal.totalDoneCount || 0;
+          newTotalTipCount += existingTotal.totalTipCount || 0; // 累加 totalTipCount
+        }
+
+        // 3. 计算总完成率
+        const newCompletionRate = newTotalStartCount > 0 ? (newTotalDoneCount / newTotalStartCount) * 100 : 0;
+
+        // 4. 使用 findByIdAndUpdate 进行原子操作
+        const updatedDoc = await TotalDoneRate.findByIdAndUpdate(
+          artworkId,
+          {
+            $set: {
+              totalStartCount: newTotalStartCount,
+              totalDoneCount: newTotalDoneCount,
+              totalTipCount: newTotalTipCount, // 写入新的累计道具使用次数
+              completionRate: newCompletionRate,
             },
-            { new: true } // 返回更新后的文档
-          );
-
-          if (remoteUpdateResult) {
-            updatedRemoteArtworksCount++;
-          } else {
-            console.warn(`[DoneRate Cron] Remote Art document with ID ${artworkId} not found. Skipping remote update.`);
-          }
-
-          // ============= 新增进度日志 =============
-          if ((i + 1) % 50 === 0 || i === totalDoneRates - 1) {
-            const elapsed = new Date().getTime() - remoteUpdateStartTime;
-            console.log(`[DoneRate Cron] 进度: 已更新 ${i + 1}/${totalDoneRates} 条远程 Art 记录, 当前耗时: ${elapsed}ms`);
-          }
-          // ==========================================
-        } else {
-          console.warn(`[DoneRate Cron] Local Art document with ID ${artworkId} not found for DoneRate record (date: ${doneRateDoc.date}). Skipping Art update.`);
+          },
+          { new: true, upsert: true } // upsert: true 表示如果文档不存在则创建
+        );
+
+        if (updatedDoc) {
+          updatedTotalDoneRateCount++;
+        }
+
+        // 进度日志
+        if ((i + 1) % 50 === 0 || i === totalDoneRatesToUpdate - 1) {
+          const elapsed = new Date().getTime() - updateStartTime;
+          console.log(`[DoneRate Cron] 进度: 已更新 ${i + 1}/${totalDoneRatesToUpdate} 条本地 TotalDoneRate 记录, 当前耗时: ${elapsed}ms`);
         }
-      } catch (artUpdateError) {
-        console.error(`[DoneRate Cron] Error updating Art document for artwork ID ${doneRateDoc.res}:`, artUpdateError);
+      } catch (totalUpdateError) {
+        console.error(`[DoneRate Cron] Error updating TotalDoneRate document for artwork ID ${doneRateDoc.res}:`, totalUpdateError);
       }
     }
 
-    // ============= 新增总结日志 =============
-    const remoteUpdateTimeTaken = new Date().getTime() - remoteUpdateStartTime;
-    console.log(`[DoneRate Cron] 远程 Art 表更新完成。总计更新 ${updatedRemoteArtworksCount} 条记录,总耗时 ${remoteUpdateTimeTaken}ms。`);
-    // ========================================
+    const updateTimeTaken = new Date().getTime() - updateStartTime;
+    console.log(`[DoneRate Cron] 本地 TotalDoneRate 表更新完成。总计更新 ${updatedTotalDoneRateCount} 条记录,总耗时 ${updateTimeTaken}ms。`);
 
-    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}.`;
+    const summary = `[DoneRate Cron] Daily done-rate calculation for ${yesterdayYYYYMMDD} completed. Total DoneRate processed: ${totalProcessedArtworks}. Created DoneRate: ${createdRecordsCount}, Updated DoneRate: ${updatedRecordsCount}. Updated TotalDoneRate records: ${updatedTotalDoneRateCount}.`;
     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.");
-    }
   }
 }
 

+ 0 - 201
oms/services/cron-jobs/done-rate2.ts

@@ -1,201 +0,0 @@
-// oms/services/cron-jobs/done-rate.ts
-
-import dayjs from "dayjs";
-import doneRateService from "../../src/services/doneRateService"; // 导入 DoneRateService
-import { clickhouseService } from "../../src/services/clients";
-import mongoose, { Connection } from "mongoose"; // 导入 mongoose 和 Connection 用于处理远程连接
-import TotalDoneRate from "../../src/models/totalDoneRateModel"; // 导入新的 TotalDoneRate 模型
-
-// ClickHouse 表名
-const CLICKHOUSE_EVENTS_TABLE = "events"; // 确保与 ClickHouseService 中的表名一致
-
-/**
- * ClickHouse 查询结果接口:每日每个作品的独立开始用户数
- */
-interface ClickHouseStartCountResult {
-  res: string; // 作品 ID
-  unique_starts: number; // 独立开始用户数
-}
-
-/**
- * ClickHouse 查询结果接口:每日每个作品的独立完成用户数
- */
-interface ClickHouseDoneCountResult {
-  res: string; // 作品 ID
-  unique_dones: number; // 独立完成用户数
-}
-
-/**
- * 每日统计昨天的作品完成率。
- * 统计逻辑从 ClickHouse 中提取数据,得到每个作品的完成情况,并更新到 doneRateModel。
- * 随后,根据这些日统计数据,累加更新本地的 totalDoneRate 表。
- * @returns Promise<string> - 返回统计结果的摘要信息。
- */
-async function run(): Promise<string> {
-  console.log("[DoneRate Cron] Starting daily done-rate calculation for yesterday..."); // 获取昨天和今天的日期
-
-  // 获取昨天和今天的日期
-  const yesterday = dayjs().subtract(1, "day");
-  const yesterdayYYYYMMDD = yesterday.format("YYYYMMDD");
-  const yesterdayStart = yesterday.startOf("day").toDate();
-  const yesterdayEnd = yesterday.endOf("day").toDate();
-
-  // 格式化日期字符串,使其符合 ClickHouse 的 toDateTime() 函数要求
-  const yesterdayStartString = dayjs(yesterdayStart).format("YYYY-MM-DD HH:mm:ss");
-  const yesterdayEndString = dayjs(yesterdayEnd).format("YYYY-MM-DD HH:mm:ss");
-
-  console.log(`[DoneRate Cron] Processing data for date: ${yesterdayYYYYMMDD}`);
-
-  try {
-    // --- 1. 从 ClickHouse 中提取数据 ---
-
-    // 查询昨天每个作品的独立开始用户数
-    const startCountsQuery = `
-      SELECT
-          res,
-          count(DISTINCT uid) AS unique_starts
-      FROM ${CLICKHOUSE_EVENTS_TABLE}
-      WHERE event = 'color_start'
-        AND time >= toDateTime('${yesterdayStartString}')
-        AND time < toDateTime('${yesterdayEndString}')
-      GROUP BY res
-      HAVING res IS NOT NULL
-    `;
-    const startResults = await clickhouseService.queryEvents<ClickHouseStartCountResult>(startCountsQuery);
-    const artworkStartCounts = new Map<string, number>();
-    startResults.forEach((row) => {
-      if (row.res && mongoose.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('${yesterdayStartString}')
-        AND time < toDateTime('${yesterdayEndString}')
-      GROUP BY res
-      HAVING res IS NOT NULL
-    `;
-    const doneResults = await clickhouseService.queryEvents<ClickHouseDoneCountResult>(doneCountsQuery);
-    const artworkDoneCounts = new Map<string, number>();
-    doneResults.forEach((row) => {
-      if (row.res && mongoose.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.Types.ObjectId(resIdStr);
-
-      // 使用 DoneRateService 来创建或更新记录
-      const doneRateDoc = await doneRateService.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.Types.ObjectId(resIdStr);
-
-      const doneRateDoc = await doneRateService.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 记录,并更新本地的 totalDoneRate 表 ---
-
-    let updatedTotalDoneRateCount = 0;
-    const yesterdayDoneRates = await doneRateService.getDoneRatesByDate(yesterdayYYYYMMDD);
-
-    const totalDoneRatesToUpdate = yesterdayDoneRates.length;
-    let updateStartTime = new Date().getTime();
-    console.log(`[DoneRate Cron] 开始更新本地 TotalDoneRate 表。总计 ${totalDoneRatesToUpdate} 条记录。`);
-
-    for (let i = 0; i < totalDoneRatesToUpdate; i++) {
-      const doneRateDoc = yesterdayDoneRates[i];
-      try {
-        const artworkId = doneRateDoc.res; // 获取作品 ObjectId
-
-        // 查找现有的 TotalDoneRate 文档
-        const existingTotal = await TotalDoneRate.findById(artworkId);
-
-        let newTotalStartCount = doneRateDoc.startCount;
-        let newTotalDoneCount = doneRateDoc.doneCount;
-
-        if (existingTotal) {
-          // 如果已存在,则累加
-          newTotalStartCount += existingTotal.totalStartCount || 0;
-          newTotalDoneCount += existingTotal.totalDoneCount || 0;
-        }
-
-        const newCompletionRate = newTotalStartCount > 0 ? (newTotalDoneCount / newTotalStartCount) * 100 : 0;
-
-        // 使用 findByIdAndUpdate 进行原子操作,确保数据一致性
-        // 这里的 _id 是根据 artworkId 来查找或创建的
-        const updatedDoc = await TotalDoneRate.findByIdAndUpdate(
-          artworkId,
-          {
-            $set: {
-              totalStartCount: newTotalStartCount,
-              totalDoneCount: newTotalDoneCount,
-              completionRate: newCompletionRate,
-            },
-          },
-          { new: true, upsert: true } // upsert: true 表示如果文档不存在则创建
-        );
-
-        if (updatedDoc) {
-          updatedTotalDoneRateCount++;
-        }
-
-        // 进度日志
-        if ((i + 1) % 50 === 0 || i === totalDoneRatesToUpdate - 1) {
-          const elapsed = new Date().getTime() - updateStartTime;
-          console.log(`[DoneRate Cron] 进度: 已更新 ${i + 1}/${totalDoneRatesToUpdate} 条本地 TotalDoneRate 记录, 当前耗时: ${elapsed}ms`);
-        }
-      } catch (totalUpdateError) {
-        console.error(`[DoneRate Cron] Error updating TotalDoneRate document for artwork ID ${doneRateDoc.res}:`, totalUpdateError);
-      }
-    }
-
-    const updateTimeTaken = new Date().getTime() - updateStartTime;
-    console.log(`[DoneRate Cron] 本地 TotalDoneRate 表更新完成。总计更新 ${updatedTotalDoneRateCount} 条记录,总耗时 ${updateTimeTaken}ms。`);
-
-    const summary = `[DoneRate Cron] Daily done-rate calculation for ${yesterdayYYYYMMDD} completed. Total DoneRate processed: ${totalProcessedArtworks}. Created DoneRate: ${createdRecordsCount}, Updated DoneRate: ${updatedRecordsCount}. Updated TotalDoneRate records: ${updatedTotalDoneRateCount}.`;
-    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 {
-  }
-}
-
-export = { run }; // 导出 run 函数以供 cron-jobs/index.ts 使用

+ 1 - 1
oms/services/cron-jobs/index.ts

@@ -13,7 +13,7 @@ interface CronJobModule {
 // Each element: [name: string, schedule: string, jobModule: CronJobModule]
 const settings: [string, string, CronJobModule][] = [
   // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
-  ["done-rate", "10 0 * * *", require("./done-rate2") as CronJobModule], // 每天凌晨0点10分, 统计作品完成率
+  ["done-rate", "10 0 * * *", require("./done-rate") as CronJobModule], // 每天凌晨0点10分, 统计作品完成率
   ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector") as CronJobModule], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
   // ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了
   // ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify") as CronJobModule], // 每天下午6点,开始活跃用户新作品消息推送

+ 41 - 0
oms/services/cron-jobs/initialize-tip-count.ts

@@ -0,0 +1,41 @@
+import DoneRate from "../../src/models/doneRateModel";
+import mongoose from "mongoose";
+
+/**
+ * 初始化所有 DoneRate 历史文档中缺失的 tipCount 字段为 0。
+ * 确保所有文档在开始回填前拥有 tipCount 字段,以保证数据一致性。
+ * @returns Promise<string> - 返回初始化结果的摘要信息。
+ */
+async function runInitialization(): Promise<string> {
+  console.log("[TipCount Initialization] Starting to set missing tipCount field to 0 for all historical DoneRate documents...");
+  const startTime = Date.now();
+
+  try {
+    // 查找条件: 查找 tipCount 字段不存在的文档 (即历史数据)
+    const filter = { tipCount: { $exists: false } };
+    // 更新操作: 将 tipCount 设置为 0
+    const update = { $set: { tipCount: 0 } };
+    // 选项: 更新所有匹配的文档
+    const options = { multi: true };
+
+    const result = await DoneRate.updateMany(filter, update, options).exec();
+
+    const endTime = Date.now();
+    const timeTaken = ((endTime - startTime) / 1000).toFixed(2);
+
+    if (result.modifiedCount === 0) {
+      console.log("[TipCount Initialization] No missing tipCount fields found. Historical data seems consistent or already initialized.");
+    } else {
+      console.log(`[TipCount Initialization] Successfully initialized ${result.modifiedCount} historical DoneRate documents to tipCount: 0.`);
+    }
+
+    const summary = `[TipCount Initialization] Initialization completed in ${timeTaken} seconds. Total documents modified: ${result.modifiedCount}.`;
+    console.log(`\n${summary}`);
+    return summary;
+  } catch (error) {
+    console.error("[TipCount Initialization] Fatal error during initialization:", error);
+    throw new Error("Failed to initialize DoneRate tipCount field.");
+  }
+}
+
+export = { runInitialization };

+ 94 - 0
oms/services/cron-jobs/recalculate-total-done-rate.ts

@@ -0,0 +1,94 @@
+import mongoose from "mongoose";
+import DoneRate from "../../src/models/doneRateModel";
+import TotalDoneRate from "../../src/models/totalDoneRateModel";
+
+/**
+ * 重新计算 TotalDoneRate 表中的所有累计字段。
+ * 通过聚合 DoneRate 表中的所有历史数据来实现。
+ * 此脚本应在 DoneRate 表的 tipCount 字段回填完成后运行。
+ * @returns Promise<string> - 返回累计更新结果的摘要信息。
+ */
+async function runRecalculation(): Promise<string> {
+  console.log("[TotalDoneRate Recalculation] Starting full recalculation of TotalDoneRate table...");
+  const startTime = Date.now();
+
+  try {
+    // 1. 使用聚合操作,从 DoneRate 表中计算每个作品的总累计数据
+    console.log("[TotalDoneRate Recalculation] Step 1: Aggregating all DoneRate records...");
+
+    // 聚合管道定义
+    const aggregationPipeline = [
+      {
+        // 1. 按作品 ID (res) 分组
+        $group: {
+          _id: "$res", // 使用 res 作为分组键
+          totalStartCount: { $sum: "$startCount" },
+          totalDoneCount: { $sum: "$doneCount" },
+          totalTipCount: { $sum: "$tipCount" }, // 累加 tipCount
+        },
+      },
+      {
+        // 2. 计算最终的 completionRate
+        $project: {
+          _id: "$_id",
+          totalStartCount: "$totalStartCount",
+          totalDoneCount: "$totalDoneCount",
+          totalTipCount: "$totalTipCount",
+          completionRate: {
+            $cond: [
+              { $gt: ["$totalStartCount", 0] },
+              { $multiply: [{ $divide: ["$totalDoneCount", "$totalStartCount"] }, 100] },
+              0, // 如果 totalStartCount 为 0,则 completionRate 为 0
+            ],
+          },
+        },
+      },
+    ];
+
+    const aggregatedResults = await DoneRate.aggregate(aggregationPipeline).exec();
+
+    console.log(`[TotalDoneRate Recalculation] Aggregation complete. Found ${aggregatedResults.length} unique artworks to update.`);
+
+    // 2. 批量更新 TotalDoneRate 表
+    const bulkOps = aggregatedResults.map((result) => ({
+      updateOne: {
+        // 查找条件:使用聚合结果的 _id (即作品 ObjectId)
+        filter: { _id: result._id },
+        // 更新操作:设置所有累计字段
+        update: {
+          $set: {
+            totalStartCount: result.totalStartCount,
+            totalDoneCount: result.totalDoneCount,
+            totalTipCount: result.totalTipCount, // 设置重新计算的总道具使用数
+            completionRate: result.completionRate,
+          },
+        },
+        // 如果 TotalDoneRate 记录不存在,则创建新记录
+        upsert: true,
+      },
+    }));
+
+    if (bulkOps.length > 0) {
+      console.log(`[TotalDoneRate Recalculation] Step 2: Running bulk update for ${bulkOps.length} documents...`);
+      const updateResult = await TotalDoneRate.bulkWrite(bulkOps, { ordered: false });
+
+      const modifiedCount = (updateResult.modifiedCount || 0) + (updateResult.upsertedCount || 0);
+
+      const endTime = Date.now();
+      const timeTaken = ((endTime - startTime) / 1000).toFixed(2);
+
+      const summary = `[TotalDoneRate Recalculation] Full recalculation completed in ${timeTaken} seconds. Total artworks updated/created: ${modifiedCount}.`;
+      console.log(`\n${summary}`);
+      return summary;
+    } else {
+      const summary = "[TotalDoneRate Recalculation] Aggregation returned no results. TotalDoneRate table update skipped.";
+      console.log(summary);
+      return summary;
+    }
+  } catch (error) {
+    console.error("[TotalDoneRate Recalculation] Fatal error during full recalculation:", error);
+    throw new Error("Failed to recalculate TotalDoneRate history.");
+  }
+}
+
+export = { runRecalculation };

+ 7 - 0
oms/src/models/doneRateModel.ts

@@ -7,6 +7,7 @@ export interface IDoneRate extends Document {
   res: mongoose.Types.ObjectId; // 作品 ID,关联到 Art 模型
   startCount: number; // 今日该作品点击进入填色的次数
   doneCount: number; // 今日该作品的完成数
+  tipCount: number; // 今日该作品使用道具数
   completionRate: number; // 完成率: 100 * doneCount / startCount
   createdAt: Date; // 创建时间 (由 timestamps 自动管理)
   updatedAt: Date; // 更新时间 (由 timestamps 自动管理)
@@ -39,6 +40,12 @@ const DoneRateSchema: Schema = new Schema(
       default: 0,
       min: 0, // 确保完成次数不为负
     },
+    tipCount: {
+      type: Number,
+      required: true,
+      default: 0,
+      min: 0, // 确保道具使用次数不为负
+    },
     completionRate: {
       type: Number,
       required: true,

+ 6 - 0
oms/src/models/totalDoneRateModel.ts

@@ -10,6 +10,7 @@ export interface ITotalDoneRate extends Document {
   _id: mongoose.Schema.Types.ObjectId; // 直接使用作品的 ObjectId 作为 _id
   totalStartCount: number; // 作品总的开始次数
   totalDoneCount: number; // 作品总的完成次数
+  totalTipCount: number; // 作品总的道具使用次数
   completionRate: number; // 作品的总完成率 (百分比, 0-100)
 }
 
@@ -27,6 +28,11 @@ const totalDoneRateSchema: Schema = new Schema(
       required: true,
       default: 0,
     },
+    totalTipCount: {
+      type: Number,
+      required: true,
+      default: 0,
+    },
     completionRate: {
       type: Number,
       required: true,

+ 32 - 13
oms/src/services/doneRateService.ts

@@ -12,12 +12,20 @@ class DoneRateService {
    * @param resId - 作品 ID (mongoose.Types.ObjectId)。
    * @param startCount - 今日该作品点击进入填色的次数。
    * @param doneCount - 今日该作品的完成数。
+   * @param tipCount - 今日该作品使用道具数。 <--- 新增参数
    * @returns 创建或更新后的 DoneRate 文档。
    */
-  public async createOrUpdateDoneRate(date: string, resId: mongoose.Types.ObjectId, startCount: number, doneCount: number): Promise<IDoneRate> {
+  public async createOrUpdateDoneRate(
+    date: string,
+    resId: mongoose.Types.ObjectId,
+    startCount: number,
+    doneCount: number,
+    tipCount: number // <--- 接收 tipCount
+  ): Promise<IDoneRate> {
     try {
-      if (startCount < 0 || doneCount < 0) {
-        throw new Error("startCount and doneCount cannot be negative.");
+      if (startCount < 0 || doneCount < 0 || tipCount < 0) {
+        // <--- 增加 tipCount 校验
+        throw new Error("startCount, doneCount, and tipCount cannot be negative.");
       }
 
       // 计算完成率
@@ -28,6 +36,7 @@ class DoneRateService {
         $set: {
           startCount: startCount,
           doneCount: doneCount,
+          tipCount: tipCount, // <--- 更新 tipCount
           completionRate: completionRate,
         },
         $setOnInsert: {
@@ -103,21 +112,31 @@ class DoneRateService {
       delete mutableUpdateData.res;
 
       // 如果提供了 startCount 或 doneCount,重新计算 completionRate
-      if ((mutableUpdateData.startCount !== undefined && mutableUpdateData.startCount >= 0) || (mutableUpdateData.doneCount !== undefined && mutableUpdateData.doneCount >= 0)) {
+      // 检查是否传入了任何需要重新计算完成率的字段
+      const shouldRecalculateRate =
+        (mutableUpdateData.startCount !== undefined && mutableUpdateData.startCount >= 0) || (mutableUpdateData.doneCount !== undefined && mutableUpdateData.doneCount >= 0);
+
+      if (shouldRecalculateRate) {
         // 先获取现有记录,以计算正确的完成率
         const existingRecord = await DoneRate.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;
+
+        let currentStartCount = existingRecord ? existingRecord.startCount : 0;
+        let currentDoneCount = existingRecord ? existingRecord.doneCount : 0;
+
+        // 应用传入的更新值
+        if (mutableUpdateData.startCount !== undefined) {
+          currentStartCount = mutableUpdateData.startCount;
         }
+        if (mutableUpdateData.doneCount !== undefined) {
+          currentDoneCount = mutableUpdateData.doneCount;
+        }
+
+        // 计算新的完成率
+        mutableUpdateData.completionRate = currentStartCount > 0 ? (currentDoneCount / currentStartCount) * 100 : 0;
       }
 
+      // 注意:tipCount 不需要特殊处理,它直接作为可更新字段包含在 mutableUpdateData 中
+
       const updatedDoneRate = await DoneRate.findOneAndUpdate(
         { date, res: resId },
         { $set: mutableUpdateData },